REST — Filtering¶
PostgREST-style filter operators work on every column of every table. The
shape is always ?<column>=<operator>.<value>. Filters compose with an
implicit AND. Negate any filter by prefixing not.:
?<column>=not.<operator>.<value>.
Which operators work on my database?
Operator availability varies by backend. See the database compatibility matrix for the canonical list — the same engine drives both REST and GraphQL, so the matrix applies to both.
For the GraphQL equivalent of every operator below, see GraphQL filtering. For when to pick one protocol over the other, see Choose Your Protocol.
Quickstart¶
# Eq
GET /api/v1/issues?status=eq.todo
# Range
GET /api/v1/issues?story_points=gte.5&story_points=lte.13
# In list
GET /api/v1/issues?status=in.(todo,in_progress,done)
# Null check
GET /api/v1/issues?parent_issue_id=is.null
# Text search
GET /api/v1/issues?description=plfts.payment
# Multi-filter (AND)
GET /api/v1/issues?status=eq.todo&priority=eq.high&story_points=gte.3
Every example below runs against a live stack — URL-encode special characters
({, }, (, ), spaces, non-ASCII) with encodeURIComponent before
sending. Tomcat rejects raw reserved characters in query strings with a 400
before the request reaches the controller.
Equality and comparison¶
| Op | SQL | Example |
|---|---|---|
eq |
col = :v |
?status=eq.todo |
neq |
col != :v |
?status=neq.done |
gt |
col > :v |
?story_points=gt.5 |
gte |
col >= :v |
?story_points=gte.5 |
lt |
col < :v |
?story_points=lt.13 |
lte |
col <= :v |
?story_points=lte.13 |
# Integer comparisons
GET /api/v1/issues?story_points=gte.5&story_points=lt.13
# String equality
GET /api/v1/customers?email=eq.alice@shop.com
List membership¶
| Op | SQL | Example |
|---|---|---|
in.(v1,v2,...) |
col IN (:v1, :v2, ...) |
?status=in.(todo,done) |
notin.(v1,v2,...) |
col NOT IN (:v1, :v2, ...) |
?id=notin.(1,2,3) |
# Enum column
GET /api/v1/issues?status=in.(todo,done)
# Integer column
GET /api/v1/issues?id=in.(1,2,3)
# Compose with other filters
GET /api/v1/issues?status=in.(todo,done)&priority=eq.high
Both in and notin coerce values through the column type
Int columns bind as INT, enum columns bind with a Postgres enum cast,
text columns bind as TEXT. Values go through the same type-conversion
path as single-value operators like eq.
Null checks¶
| Op | SQL | Example |
|---|---|---|
is.null |
col IS NULL |
?parent_issue_id=is.null |
is.notnull |
col IS NOT NULL |
?parent_issue_id=is.notnull |
not.is.null |
col IS NOT NULL |
?parent_issue_id=not.is.null |
GET /api/v1/issues?parent_issue_id=is.null # top-level issues
GET /api/v1/issues?parent_issue_id=is.notnull # child issues only
Text operators¶
| Op | SQL | Example |
|---|---|---|
like |
col LIKE :v (use * for %) |
?title=like.*Setup* |
ilike |
col ILIKE :v |
?title=ilike.*setup* |
startswith |
col LIKE 'prefix%' |
?title=startswith.Setup |
endswith |
col LIKE '%suffix' |
?title=endswith.auth |
match |
col ~ :v (POSIX regex, case-sensitive) |
?title=match.%5ESetup |
imatch |
col ~* :v (POSIX regex, case-insensitive) |
?title=imatch.%5Esetup |
# LIKE with wildcards — use * instead of %
GET /api/v1/issues?title=like.*Setup*
# Case-insensitive prefix search
GET /api/v1/issues?title=ilike.setup*
# POSIX regex (^Setup anchor)
GET /api/v1/issues?title=match.%5ESetup # url-encoded ^
^, {, }, [, ] must be url-encoded
Tomcat rejects unencoded reserved characters in query strings with a
400 Bad Request before the controller sees them. In JS use
encodeURIComponent(value).
JSON operators (jsonb)¶
| Op | SQL | Example |
|---|---|---|
haskey |
col ? :key (via jsonb_exists) |
?address=haskey.city |
jsoncontains / cs |
col @> :v::jsonb |
?address=cs.%7B%22city%22%3A%22NY%22%7D |
jsoncontained / cd |
col <@ :v::jsonb |
?metadata=cd.%7B%22brand%22%3A%22Apple%22%7D |
jsonpath |
jsonb_path_exists(col, :v::jsonpath) |
?metadata=jsonpath.%24.brand |
jsonpathexists |
alias for jsonpath |
?metadata=jsonpathexists.%24.price |
# Key exists
GET /api/v1/customers?address=haskey.city
# Containment — value is a URL-encoded JSON blob
# {"city":"New York"} → %7B%22city%22%3A%22New%20York%22%7D
GET /api/v1/customers?address=cs.%7B%22city%22%3A%22New%20York%22%7D
# Jsonpath existence
GET /api/v1/products?metadata=jsonpath.%24.brand
jsonpath uses the function form, not the @? operator
Postgres's @? operator contains a ? character which clashes with
Spring's NamedParameterJdbcTemplate placeholder parser. The compiler
emits jsonb_path_exists(col, :v::jsonpath) instead — same semantics,
no parser collision.
Array operators (for text[], int[], etc.)¶
| Op | SQL | Example |
|---|---|---|
arraycontains / arrayhasall |
col @> :v |
?tags=arraycontains.%7Bnew,featured%7D |
arrayhasany / ov |
col && :v |
?tags=arrayhasany.%7Bnew,clearance%7D |
arraylength |
array_length(col, 1) = :n |
?tags=arraylength.3 |
# Contains all listed tags
GET /api/v1/patients?allergies=arraycontains.%7Bpenicillin,aspirin%7D
# Has any of the listed tags
GET /api/v1/patients?allergies=arrayhasany.%7Bpenicillin,aspirin%7D
# Exact array length
GET /api/v1/patients?allergies=arraylength.2
Array literal syntax
Use Postgres array literal form {a,b,c} — not [a,b,c]. Element
coercion happens through the column type, so text[] columns bind
text elements and int[] columns bind integers.
Full-text search (tsvector columns)¶
| Op | Postgres function | Use when |
|---|---|---|
plfts |
plainto_tsquery |
Default search box — raw user input, always safe |
wfts |
websearch_to_tsquery |
Users may type "phrase" / OR / -exclude |
phfts |
phraseto_tsquery |
Words must be adjacent in the document |
fts |
to_tsquery |
Raw tsquery syntax (foo & bar \| baz), throws on bad input |
GET /api/v1/issues?search_vec=plfts.stripe
GET /api/v1/issues?search_vec=wfts.stripe%20OR%20benchmarks
GET /api/v1/issues?search_vec=phfts.webhook%20handler
GET /api/v1/issues?search_vec=fts.jwt%20%26%20auth
See the Full-Text & Vector Search guide for the full tsvector setup (generated columns, GIN indexes) and how to pick between the variants.
Vector k-NN (pgvector columns)¶
The vector operator replaces the query's ORDER BY and LIMIT with a k-NN
similarity search. The value is a URL-encoded JSON blob:
# URL-encoded shape (readable form):
GET /api/v1/issues?embedding=vector.{"near":[0.1,0.2,0.3],"distance":"COSINE","limit":5}
// From JavaScript — encode the JSON blob
const vec = { near: [0.1, 0.2, 0.3], distance: "COSINE", limit: 5 };
const url = `/api/v1/issues?embedding=vector.${encodeURIComponent(JSON.stringify(vec))}`;
Field reference:
| Field | Required | Description |
|---|---|---|
near |
yes | Query embedding (array of floats matching column dimensionality) |
distance |
no | L2 (default) / COSINE / IP. Aliases: EUCLIDEAN, INNER_PRODUCT |
limit |
no | Result count, clamped to app.max-rows (default 30) |
Vector combines with other filters — they run as WHERE predicates, then
the k-NN clause orders the survivors:
See the Full-Text & Vector Search guide for distance metric tradeoffs, index tuning (HNSW), and REST vs GraphQL syntax differences.
Composing filters (AND / OR)¶
AND (default): every filter in the query string is ANDed together.
GET /api/v1/issues?status=eq.todo&priority=eq.high&story_points=gte.5
# WHERE status = 'todo' AND priority = 'high' AND story_points >= 5
OR is supported via PostgREST-style logical trees on the or parameter:
GET /api/v1/issues?or=(status.eq.todo,priority.eq.critical)
# WHERE (status = 'todo' OR priority = 'critical')
Nest OR inside AND:
GET /api/v1/issues?project_id=eq.1&or=(status.eq.todo,status.eq.in_progress)
# WHERE project_id = 1 AND (status = 'todo' OR status = 'in_progress')
Nesting parentheses deeper is allowed.
Ordering¶
GET /api/v1/issues?order=id.desc
GET /api/v1/issues?order=priority.desc,created_at.asc
GET /api/v1/issues?order=story_points.desc.nullslast
Supported suffixes: .asc, .desc, .nullsfirst, .nullslast.
Compose with pagination + projection¶
GET /api/v1/issues\
?select=id,title,status\
&status=in.(todo,in_progress)\
&order=id.desc\
&limit=20\
&offset=0
For Content-Range headers + Prefer: count=exact total counts, see
Pagination.
What's NOT supported in REST¶
- GraphQL enum filter narrowing — REST values are always strings, so invalid enum values produce Postgres errors instead of schema-validation errors. Prefer GraphQL when client-side type safety matters.
- Nested projection with FK traversal — REST returns flat rows. For nested shapes use GraphQL or call REST multiple times.
- Aggregate functions in one call — GraphQL's
<Table>Aggregate { count sum avg min max }has no direct REST equivalent; the closest isPrefer: count=exactfor total row count only. - WebSocket subscriptions — GraphQL only (see Real-Time Subscriptions).