Guide

Queries

Finding things. Filters, sorting, pagination, pointers, sub-queries and counting — with the permission model doing the filtering you would otherwise write by hand.

The shape of a query

Build it, constrain it, run it. Always pass a sessionToken so the ACL applies — see permissions below.

import {catchError, ParseClass, CloudFunction} from 'parse-server-kit';

const query = new Parse.Query(Product);
query.equalTo('status', 'active');
query.descending('createdAt');
query.limit(20);

const [err, rows] = await catchError(query.find({ sessionToken }));
if (err) throw err;

// Cast - Parse.Query returns Parse.Object, not your class.
const products = rows as Product[];
Always pass the model class, never the name new Parse.Query(Product), not new Parse.Query('Product'). The string form works but returns plain Parse.Objects with no relationship to your class, so nothing type-checks and instanceof fails.

Filtering

MethodFinds rows where
equalTo(k, v)the field equals v
notEqualTo(k, v)it does not
greaterThan(k, v) / lessThan(k, v)numeric or date comparison
greaterThanOrEqualTo / lessThanOrEqualToinclusive versions
containedIn(k, [v])the value is one of the list
notContainedIn(k, [v])it is not
exists(k) / doesNotExist(k)the field is set at all
containsAll(k, [v])an Array field holds every listed value
matches(k, regex)a String matches a regular expression
startsWith(k, prefix)prefix match — uses an index, unlike a general regex
// A date range - both ends, or you will get everything since 1970.
const start = new Date('2026-01-01');
const end   = new Date('2026-02-01');
query.greaterThanOrEqualTo('createdAt', start);
query.lessThan('createdAt', end);

// One of several values
query.containedIn('status', ['active', 'featured']);

// Only rows that have the field at all
query.exists('publishedAt');
// Case-insensitive substring. Simple, but it cannot use an index -
// fine for admin screens, poor for large public listings.
query.matches('name', new RegExp(search, 'i'));

// Prefix search DOES use an index. Prefer it where it fits.
query.startsWith('name', search);

// Full-text, if the field has a text index declared:
//   @ParseClass('Product', {compoundIndexes: [
//     {fields: ['name'], fieldTypes: {name: 'text'}} ]})
query.fullText('name', search);
Escape user input before building a regex A search box passed straight into new RegExp() lets a caller send .*.*.*.* and make the database work very hard. Escape it, or use startsWith / fullText. parse-server 9.8+ can also disable the $regex operator entirely via requestComplexity.allowRegex.

Combining conditions

Conditions on one query are combined with AND. For OR, build several queries and join them.

const byName = new Parse.Query(Product);
byName.matches('name', new RegExp(term, 'i'));

const bySku = new Parse.Query(Product);
bySku.matches('sku', new RegExp(term, 'i'));

const search = Parse.Query.or(byName, bySku);
search.equalTo('status', 'active');   // applies to the whole OR
search.limit(20);

Parse.Query.and(...) exists too, for when you need an AND of two OR groups.

Pointers and related data

A Pointer stores only a reference. Ask for the related row explicitly, or you get {__type, className, objectId} and nothing else.

// Fetch the category along with each product
query.include('category');

// Two levels deep
query.include('category.parent');

// Several at once
query.include(['category', 'brand']);

// Only some fields, to keep the payload small
query.select(['name', 'price', 'category.name']);
// Filter BY a pointer - pass the object or a pointer to it
query.equalTo('category', Category.pointer(categoryId));

// Filter by a property OF the pointed-to row, with a sub-query
const cheapCategories = new Parse.Query(Category);
cheapCategories.lessThan('averagePrice', 100);
query.matchesQuery('category', cheapCategories);
include() costs a lookup per level It is the right tool for a detail screen and the wrong one for a list of 500 rows with three includes. parse-server 8.5+ fetches same-level pointers in parallel, which helps, but the cheapest include is the one you do not need — consider denormalising a name or two onto the row.

Sorting and pagination

query.descending('createdAt');          // newest first
query.ascending('name');                // A to Z
query.addDescending('price');           // then by price, within that

const page  = Number(req.params.page) || 1;
const limit = Math.min(Number(req.params.limit) || 20, MAX_QUERY_LIMIT);
query.limit(limit).skip((page - 1) * limit);

limit defaults to 100 and is capped at 1000 by parse-server; MAX_QUERY_LIMIT (10000) is the ceiling this library exports for the master-key paths that allow it. Always sort when you paginate — without an order, "page 2" is not guaranteed to exclude what was on page 1.

Or use paginate

The code above is what every list endpoint ends up writing, and the obvious version of it is wrong in a way that runs. paginate does the whole job:

const query = new Parse.Query(Product).descending('createdAt');
return paginate<Product>(query, req.params, {useMasterKey: true});

// → {results, count, limit, skip, hasMore}

It reads limit and skip as the strings a GET sends, caps the limit, and asks the database for the total matching rows in the same round trip via withCount().

Why count is the total, not the page size

The hand-written version usually returns results.length. That is the size of the page you already have: a client cannot draw "page 3 of 12" from it, or decide whether to enable a next button. Nothing fails — the endpoint answers 200 with plausible JSON, and the list is quietly unusable.

A second count() query is the other common answer. It costs two round trips and can disagree with the first under concurrent writes, which shows up as a page count that flickers.

paginate deliberately does not sort for you — the right order belongs to the endpoint. It is still required: an unsorted paginated list repeats and drops rows as data changes underneath it.

Paging deeply

skip gets slower the further in you go, because the database still walks the skipped rows. For a large export, page on a sort key instead:

let cursor: Date | undefined;
while (true) {
  const q = new Parse.Query(Product).ascending('createdAt').limit(500);
  if (cursor) q.greaterThan('createdAt', cursor);

  const batch = await q.find({ useMasterKey: true });
  if (batch.length === 0) break;

  // … process the batch …
  cursor = batch[batch.length - 1].get('createdAt');
}

Fetching one row

// By id. Throws 101 when missing OR when the ACL hides it.
const [err, product] = await catchError(query.get(id, { sessionToken }));

// First match, or undefined - no throw
const [err2, maybe] = await catchError(query.first({ sessionToken }));
if (!maybe) throw new Parse.Error(101, 'Not found');

Use first() when absence is normal and get() when it is an error — it saves you writing the check.

Counting

const [err, total] = await catchError(query.count({ sessionToken }));

// A list endpoint usually wants both, from the same constraints.
const rows  = await query.find({ sessionToken });
const total2 = await query.count({ sessionToken });
return { results: rows as Product[], count: total2 };
count has its own permission count is a separate CLP operation from find, because a count leaks how much data exists even when the rows are hidden. If a count works and a find returns nothing, that is the ACL — not a bug.

Queries and permissions

This is the part that removes work rather than adding it.

// Filtered by the ACL - only rows this user may read come back.
await query.find({ sessionToken: user.getSessionToken() });

// Bypasses CLP and ACL entirely. Administrative use only.
await query.find({ useMasterKey: true });

With an ACL set when the record was created, a "my items" endpoint needs no equalTo('owner', user) at all — the database returns only what the caller may see. See Permissions & ACL.

Which means: a query returning fewer rows than you expect is usually correct. Re-run it with useMasterKey; if the rows appear, it is the ACL.

Aggregation and distinct

// Master key required for both.
const distinct = await query.distinct('status');

const pipeline = [
  { match: { status: 'active' } },
  { group: { objectId: '$category', total: { $sum: '$price' } } },
];
const totals = await new Parse.Query(Product).aggregate(pipeline);

Aggregation is raw MongoDB and skips the ACL, so treat its results as privileged and filter before returning them.

A complete list endpoint

@CloudFunction({
  methods: ['GET'],
  description: 'List products',
  validation: { fields: {
    page:   { type: String },
    limit:  { type: String },
    status: { type: String },
    search: { type: String },
  } },
})
static async listProducts(req: Parse.Cloud.FunctionRequest) {
  const sessionToken = req.user?.getSessionToken();

  const build = () => {
    const q = new Parse.Query(Product);
    if (req.params.status) q.equalTo('status', req.params.status);
    if (req.params.search) {
      q.matches('name', new RegExp(req.params.search, 'i'));
    }
    return q;
  };

  const page  = Math.max(Number(req.params.page) || 1, 1);
  const limit = Math.min(Number(req.params.limit) || 20, 100);

  const rows = build()
    .include('category')
    .descending('createdAt')
    .limit(limit)
    .skip((page - 1) * limit);

  const [err, results] = await catchError(rows.find({ sessionToken }));
  if (err) throw err;

  // A fresh query - find() and count() cannot share one that has
  // had limit and skip applied.
  const [countErr, total] = await catchError(build().count({ sessionToken }));

  return {
    results: results as Product[],
    page,
    limit,
    total: countErr ? 0 : total,
  };
}

Common mistakes

SymptomCause
Typed property access is unavailableThe result was not cast — rows as Product[]
A pointer field is {__type: 'Pointer'}No include() for it
Only 100 rows come backThat is the default limit
Rows repeat across pagesPaginating without a sort
Nothing comes back, but data existsThe ACL filtered it. Re-run with useMasterKey to confirm
Count and list disagreeSeparate CLP entries, or limit/skip left on the counted query
Search is slowAn unanchored regex cannot use an index. Use startsWith or a text index
Date filter returns everythingOnly one end of the range was set

For the full list of query operators, geo queries and relational constraints, the Parse query guide is the complete reference. This page covers what you will reach for daily.