---
title: Filtering, sorting and paging
description: Query JSON records with a small filter language, sort on any field, search text and page through results with cursors.
group: Working with data
order: 3
keywords: [query json, filter api, pagination, cursor pagination, sort, search, json query language]
---

# Filtering, sorting and paging

Listing records takes query parameters. Everything here works on
`GET …/records` and on [public collections](/docs/collections#public-and-unlisted-collections).

## Filtering

`filter` takes a small expression. It reads close to how you would say it:

```text
data.done == false
data.priority in ["high", "medium"]
data.tags contains "typescript"
data.title startsWith "Ship"
data.assignee.name == "Ayush"
createdAt >= "2026-01-01T00:00:00Z" and data.done == false
(data.a == 1 or data.b == 2) and not data.archived exists
```

Remember to URL-encode it. With curl, `-G --data-urlencode` does it for you:

```bash
curl -s -G "…/records" -H "Authorization: Bearer $BAAS_KEY" \
  --data-urlencode 'filter=data.done == false' \
  --data-urlencode 'limit=20'
```

```js
const query = new URLSearchParams({ filter: 'data.done == false', limit: '20' });
const res = await fetch(`${BASE}/workspaces/${WS}/collections/todos/records?${query}`, {
  headers: { Authorization: `Bearer ${KEY}` },
});
```

### What you can compare

|                 |                                                                                      |
| --------------- | ------------------------------------------------------------------------------------ |
| Your fields     | `data.name`, `data.profile.city`, `data.items[0].name`, `data["first name"]`         |
| Built-in fields | `id`, `createdAt`, `updatedAt`, `version`, `schemaVersion`, `createdBy`, `updatedBy` |
| Values          | `"text"`, numbers, `true`, `false`, `null`, lists like `["a","b"]`                   |
| Joining         | `and`, `or`, `not`, and parentheses                                                  |

### Operators

| Operator                    | Meaning                                                                      |
| --------------------------- | ---------------------------------------------------------------------------- |
| `==`                        | Equal                                                                        |
| `!=`                        | Not equal, or the field is missing                                           |
| `>` `>=` `<` `<=`           | Numbers compare as numbers; text compares alphabetically (so ISO dates work) |
| `in [...]` / `not in [...]` | Is (or is not) one of these                                                  |
| `contains`                  | An array contains this value                                                 |
| `startsWith`                | Text begins with this                                                        |
| `like` / `ilike`            | SQL-style patterns with `%`; `ilike` ignores case                            |
| `exists` / `not exists`     | The field is present at all                                                  |

A filter is parsed and turned into a safe database query, so text from your users cannot
be used to attack the database. A filter the parser does not understand returns
`400 INVALID_FILTER` and tells you which character it stopped at.

## Searching text

`q` searches text anywhere in the record; `searchFields` narrows it down:

```bash
curl -s -G "…/records" -H "Authorization: Bearer $BAAS_KEY" \
  --data-urlencode 'q=invoice' \
  --data-urlencode 'searchFields=title,description'
```

It is a case-insensitive "contains", meant for a search box — not a ranked full-text
engine.

## Sorting

```bash
--data-urlencode 'sort=-createdAt'     # newest first (the default)
--data-urlencode 'sort=data.priority'  # by one of your own fields
```

Put `-` in front for descending. You can sort by `createdAt`, `updatedAt`, `version`,
`id`, or any `data.` path.

## Paging

Results come back one page at a time with a cursor. Keep following `nextCursor` until it
is `null`:

```js
let cursor = null;
const all = [];
do {
  const query = new URLSearchParams({ limit: '100' });
  if (cursor) query.set('cursor', cursor);
  const res = await fetch(`${BASE}/workspaces/${WS}/collections/todos/records?${query}`, {
    headers: { Authorization: `Bearer ${KEY}` },
  });
  const page = await res.json();
  all.push(...page.data);
  cursor = page.pagination.nextCursor;
} while (cursor);
```

Cursors are safer than page numbers: no record is skipped or shown twice when rows are
added while you page. A cursor belongs to the sort it was made with, so do not change
`sort` halfway through.

Default page size is 50, maximum 200.

## Fetching less

`select` returns only the fields you name, which keeps responses small:

```bash
--data-urlencode 'select=title,done'
```

The built-in fields (`id`, `version`, timestamps) always come back.

## Date ranges and the trash

| Parameter                       | Meaning                                                 |
| ------------------------------- | ------------------------------------------------------- |
| `createdAfter`, `createdBefore` | ISO 8601 range on creation time                         |
| `updatedAfter`, `updatedBefore` | The same for last change                                |
| `deleted`                       | `exclude` (default), `include`, or `only` for the trash |

## Making queries fast

If you filter on the same field constantly, add an **index** for it in the collection's
Indexes tab. Indexes are built in the background and help `==`, `in` and range filters on
that path. Five per collection.

## Query limits

|                          |                  |
| ------------------------ | ---------------- |
| Filter length            | 2,000 characters |
| Conditions per filter    | 20               |
| Nesting depth            | 5                |
| Values in one `in [...]` | 50               |
| Depth of a field path    | 8 segments       |
| `select` fields          | 25               |

## Next steps

- [Records](/docs/records) — writing the data you are querying.
- [Errors](/docs/errors) — what to do with `400 INVALID_FILTER`.
