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[];
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
| Method | Finds 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 / lessThanOrEqualTo | inclusive 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');
Text search
// 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);
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);
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().
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 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
| Symptom | Cause |
|---|---|
| Typed property access is unavailable | The result was not cast — rows as Product[] |
A pointer field is {__type: 'Pointer'} | No include() for it |
| Only 100 rows come back | That is the default limit |
| Rows repeat across pages | Paginating without a sort |
| Nothing comes back, but data exists | The ACL filtered it. Re-run with useMasterKey to confirm |
| Count and list disagree | Separate CLP entries, or limit/skip left on the counted query |
| Search is slow | An unanchored regex cannot use an index. Use startsWith or a text index |
| Date filter returns everything | Only 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.