Skip to content

REST API

pgvis exposes a PostgREST-compatible REST API. If you’ve used PostgREST before, the query DSL is identical — existing clients work unchanged.

Routes are determined by the routing configuration.

MethodPathPurpose
GET/api/{schema}/{table}List rows (HEAD: headers only)
POST/api/{schema}/{table}Insert rows
PATCH/api/{schema}/{table}Update rows
DELETE/api/{schema}/{table}Delete rows
POST/api/{schema}/rpc/{function}Call a function
GET/api/{schema}/rpc/{function}Call a non-volatile function
GET/api/Exposed schemas, or the OpenAPI 3.0 spec
GET/pgvis/cacheData cache stats/settings and pool occupancy

GET /api/ returns {"schemas": [...], "hint": ...} by default; send Accept: application/openapi+json to get the OpenAPI 3.0 document instead (404 PGRST404 when openapi_mode is disabled). Both GET /api/ and GET /pgvis/cache authenticate exactly like the data API: when a jwt_secret is configured and there is no anon_role, an anonymous request gets 401.

Example:

Terminal window
GET /api/public/users
GET /api/analytics/events
POST /api/public/rpc/search_items

With routing.schema_in_path = false and routing.prefix = "":

GET /users
POST /rpc/my_function
GET / — exposed schemas / OpenAPI spec

The schema is resolved from the Accept-Profile / Content-Profile header or the configured default_schema.


Use the select query parameter to choose which columns appear in the response.

Terminal window
# All columns (default when select is omitted)
curl "/api/public/users"
# Specific columns
curl "/api/public/users?select=id,name,email"
# All columns explicitly
curl "/api/public/users?select=*"

Rename columns in the response using : syntax:

Terminal window
curl "/api/public/users?select=user_id:id,display_name:name"

Response:

[{"user_id": 1, "display_name": "Alice"}]

Cast a column with ::type; the cast is rendered as CAST(col AS type). Array types are accepted:

Terminal window
curl "/api/public/items?select=id,price::text,tags::text[]"

The type name is restricted to letters, digits, _, spaces and []; anything else is a 400.

Use -> (JSON) and ->> (text) to reach into json/jsonb columns, in both select and filters:

Terminal window
curl "/api/public/items?select=id,color:metadata->>color&metadata->>color=eq.red"

When aggregates are enabled (aggregates_enabled = true in config):

Terminal window
curl "/api/public/items?select=category,total_price:price.sum(),avg_price:price.avg()"

Supported aggregate functions: count, sum, avg, min, max. The non-aggregated columns in select become the GROUP BY (here: category); there is no separate group parameter. A cast after the aggregate applies to its result (price.sum()::int). With aggregates disabled the request fails with 400 PGRST123.


Filters use the column=operator.value syntax on query parameters. Any query parameter that isn’t a reserved word (select, order, limit, offset, on_conflict, columns, cursor_column, cursor_value) or a logic key (and, or, not.and, not.or) is treated as a column filter.

  • A malformed filter is a 400 PGRST100; it is never silently dropped.
  • A column may be filtered more than once: every value is kept, in order, and the filters are ANDed (price=gte.10&price=lte.100).
  • A reserved word given twice (e.g. two limits) is ambiguous and returns 400 PGRST100.
  • Filters apply to columns of the target table; filtering on an embedded resource’s columns (orders.status=eq.x) is not supported and is a 400.
OperatorSQLExample
eq=name=eq.Widget
neq!=status=neq.archived
gt>price=gt.100
gte>=age=gte.18
lt<price=lt.50
lte<=priority=lte.3
Terminal window
# Items priced between 10 and 100
curl "/api/public/items?price=gte.10&price=lte.100"
OperatorSQLExample
likeLIKEname=like.*widget*
ilikeILIKEname=ilike.*Widget*
match~ (regex)email=match.^admin
imatch~* (case-insensitive regex)email=imatch.^admin

Use * as the wildcard character (mapped to % in SQL):

Terminal window
# Names starting with "A"
curl "/api/public/users?name=like.A*"
# Case-insensitive search
curl "/api/public/items?name=ilike.*phone*"

Note: ilike, match, and imatch are Postgres-only. On SQLite, ilike is automatically rewritten to a LIKE with LOWER().

OperatorSQLExample
isISdeleted_at=is.null
isdistinctIS DISTINCT FROMstatus=isdistinct.active

Valid is values: null, notnull, true, false, unknown.

Terminal window
# Rows where deleted_at is NULL
curl "/api/public/users?deleted_at=is.null"
# Rows where active is true
curl "/api/public/users?active=is.true"
OperatorSQLExample
inINstatus=in.(active,pending,review)
Terminal window
# Items in specific categories
curl "/api/public/items?category=in.(electronics,books,clothing)"
OperatorSQLDescription
cs@>Contains
cd<@Contained by
ov&&Overlaps
Terminal window
# Array contains value
curl "/api/public/items?tags=cs.{electronics,sale}"
# JSONB contains
curl "/api/public/items?metadata=cs.{\"featured\":true}"
OperatorSQLDescription
sl<<Strictly left
sr>>Strictly right
nxr&<Does not extend to the right
nxl&>Does not extend to the left
adj`--`
OperatorSQL FunctionExample
ftsto_tsquerydescription=fts.web+development
plftsplainto_tsquerydescription=plfts.web development
phftsphraseto_tsquerydescription=phfts.web development
wftswebsearch_to_tsquerydescription=wfts.web development -mobile

Optional language config:

Terminal window
# Full-text search with English config
curl "/api/public/articles?body=fts(english).postgresql+tutorial"

Comparison and pattern operators support quantifiers for array/set matching:

QuantifierSQLExample
any= ANY(...)id=eq(any).{1,2,3}
all= ALL(...)score=gt(all).{80,90}

Prefix any operator with not. to negate it:

Terminal window
# Not equal
curl "/api/public/items?status=not.eq.archived"
# Not in list
curl "/api/public/items?category=not.in.(deprecated,removed)"
# Not null
curl "/api/public/users?email=not.is.null"
# Not like
curl "/api/public/items?name=not.like.*test*"
# Not matching regex
curl "/api/public/users?email=not.match.^temp"

Combine filters with boolean logic using and= and or= query parameters.

Terminal window
# Items that are either cheap OR in electronics
curl "/api/public/items?or=(price.lt.10,category.eq.electronics)"
Terminal window
# Items that are expensive AND in stock
curl "/api/public/items?and=(price.gt.100,in_stock.is.true)"
Terminal window
# (price < 10 OR price > 1000) AND category = electronics
curl "/api/public/items?and=(or(price.lt.10,price.gt.1000),category.eq.electronics)"

Regular query parameter filters are ANDed together. Logic filters combine with them:

Terminal window
# in_stock = true AND (category = books OR category = electronics)
curl "/api/public/items?in_stock=is.true&or=(category.eq.books,category.eq.electronics)"

Use the order query parameter:

Terminal window
# Single column ascending (default direction)
curl "/api/public/items?order=name"
# Explicit direction
curl "/api/public/items?order=price.desc"
# Multiple columns
curl "/api/public/items?order=category.asc,price.desc"

Control where NULLs appear:

Terminal window
# NULLs first
curl "/api/public/items?order=price.asc.nullsfirst"
# NULLs last
curl "/api/public/items?order=price.desc.nullslast"

Classic pagination with limit and offset:

Terminal window
# First 10 rows
curl "/api/public/items?limit=10"
# Page 3 (rows 21-30)
curl "/api/public/items?limit=10&offset=20"

The response includes a Content-Range header (the total is * unless a count is requested):

Content-Range: 20-29/*

To get the total count, use Prefer: count=exact:

0-9/156
curl "/api/public/items?limit=10" -H "Prefer: count=exact"
# 206 Partial Content

When the total is known and rows remain beyond this page, the status is 206 Partial Content; the last (or only) page is 200. A non-numeric limit/offset is 416 PGRST103. The server-side max_rows setting caps limit on reads.

For large datasets, cursor-based (keyset) pagination is more efficient than offset — it avoids the O(n) skip cost.

Terminal window
# First page: just specify the cursor column and limit
curl "/api/public/items?order=id.asc&cursor_column=id&limit=10"

The response includes an X-Next-Cursor header with the last row’s cursor value:

X-Next-Cursor: 10

Use that value for the next page:

Terminal window
# Next page
curl "/api/public/items?order=id.asc&cursor_column=id&cursor_value=10&limit=10"
  • Adds WHERE id > $cursor_value (for ascending) or WHERE id < $cursor_value (for descending)
  • Automatically injects the cursor column into ORDER BY if not already present
  • Ignores any offset parameter when cursor is active
  • Returns X-Next-Cursor header only when there are results

If cursor_column is omitted but the table has a primary key, the PK is used automatically:

Terminal window
# Uses the table's primary key as cursor column
curl "/api/public/items?cursor_value=42&limit=10"
Terminal window
# Newest first, paginate backward
curl "/api/public/items?order=id.desc&cursor_column=id&cursor_value=100&limit=10"
# SQL: WHERE id < 100 ORDER BY id DESC LIMIT 10
  • If the cursor column doesn’t exist → 400 PGRST204
  • If the table has no PK and no cursor_column is specified → 416 PGRST103

Traverse foreign-key relationships and embed related resources as nested JSON using the select parameter.

The examples below use this schema. Solid arrows are foreign keys; an embed can follow one in either direction, and the dashed edge is the many-to-many relationship pgvis derives from the order_items junction table:

flowchart LR
    orders["<b>orders</b>"] -->|user_id| users["<b>users</b>"]
    oi["<b>order_items</b>"] -->|order_id| orders
    oi -->|item_id| items["<b>items</b>"]
    orders -.->|"items(...)"| items
FromEmbedDirectionShape in the JSON
ordersusers(...)many-to-oneobject (or null)
usersorders(...)one-to-manyarray
ordersitems(...)many-to-manyarray
Terminal window
# Orders with their user (orders.user_id → users.id)
curl "/api/public/orders?select=id,total,users(id,name)"

Response:

[
{
"id": 1,
"total": 99.99,
"users": {"id": 1, "name": "Alice"}
}
]
Terminal window
# Users with their orders (users.id ← orders.user_id)
curl "/api/public/users?select=id,name,orders(id,total,status)"

Response:

[
{
"id": 1,
"name": "Alice",
"orders": [
{"id": 1, "total": 99.99, "status": "completed"},
{"id": 2, "total": 45.00, "status": "pending"}
]
}
]
Terminal window
# Orders with their items (via order_items junction)
curl "/api/public/orders?select=id,total,items(id,name,price)"

pgvis automatically detects M2M relationships through junction tables.

Terminal window
# Users → Orders → Items (three levels)
curl "/api/public/users?select=id,name,orders(id,total,items(name,price))"
Terminal window
# Orders for a specific user, with items embedded
curl "/api/public/orders?user_id=eq.1&select=id,total,items(*)"
Terminal window
# Only specific columns from the embedded resource
curl "/api/public/orders?select=id,users(name,email)"
Terminal window
# Disambiguate when two foreign keys link the same tables
curl "/api/public/orders?select=id,users!orders_billing_user_fk(name)"
# Only orders that have at least one item (the embed acts as an inner join)
curl "/api/public/orders?select=id,items!inner(name)"

An ambiguous embed without a hint is 300 PGRST201; a missing relationship is 400 PGRST200.


Terminal window
# Single row
curl -X POST "/api/public/items" \
-H "Content-Type: application/json" \
-d '{"name": "Widget", "price": 9.99, "category": "gadgets"}'
# Bulk insert
curl -X POST "/api/public/items" \
-H "Content-Type: application/json" \
-d '[{"name": "A", "price": 1.0}, {"name": "B", "price": 2.0}]'

On Postgres the rows travel as a single JSON parameter read through json_populate_recordset, so there is no bind-parameter ceiling on bulk inserts, and JSON arrays/objects land in array, composite and json columns. In a bulk insert the column list is the union of every row’s keys; a row that omits one of them inserts NULL for it.

An insert answers 201 Created (empty body unless Prefer: return=representation). For a single-row insert into a table with a primary key, a Location header points at the new row (/api/public/items?id=eq.42).

Bodies are checked against the table before any SQL is built:

BodyResult
Not a JSON object or an array of objects400 PGRST102
A key that isn’t a column of the table400 PGRST204 (with a “did you mean” hint)
[] or bulk rows with no columns400 PGRST102
PATCH with an array, or with {}400 PGRST102
limit/offset on an insert400 PGRST100
Terminal window
# Insert or update on conflict (merge duplicates)
curl -X POST "/api/public/items" \
-H "Content-Type: application/json" \
-H "Prefer: resolution=merge-duplicates" \
-d '{"id": 1, "name": "Updated Widget", "price": 12.99}'
# Insert or ignore on conflict
curl -X POST "/api/public/items" \
-H "Prefer: resolution=ignore-duplicates" \
-d '{"id": 1, "name": "Ignored", "price": 0}'

Updates rows matching the filter criteria:

Terminal window
# Update price for a specific item
curl -X PATCH "/api/public/items?id=eq.1" \
-H "Content-Type: application/json" \
-d '{"price": 12.99}'
# Update multiple rows
curl -X PATCH "/api/public/items?category=eq.gadgets" \
-H "Content-Type: application/json" \
-d '{"in_stock": false}'

⚠️ A PATCH without filters updates ALL rows. Be careful.

Deletes rows matching the filter criteria:

Terminal window
# Delete a specific item
curl -X DELETE "/api/public/items?id=eq.1"
# Delete with multiple filters
curl -X DELETE "/api/public/items?category=eq.deprecated&in_stock=is.false"

By default PATCH and DELETE answer 204 No Content; send Prefer: return=representation to get the affected rows back with 200.

limit and offset (with an optional order) bound how many rows a PATCH or DELETE changes. The rows are picked by a subquery on the row identifier (ctid on Postgres, rowid on SQLite):

Terminal window
# Delete the 10 oldest archived items
curl -X DELETE "/api/public/items?status=eq.archived&order=created_at.asc&limit=10"
# WHERE ctid IN (SELECT ctid FROM items WHERE status = $1 ORDER BY created_at ASC LIMIT 10)

Views have no row identifier, so a limited PATCH/DELETE on a view is a 400. The server’s max_rows cap never applies to mutations.

Two guards are checked against the affected-row count inside the transaction, before COMMIT; a violation rolls the whole statement back, so no rows change:

RequestViolation
Prefer: handling=strict, max-affected=Nmore than N rows affected → 400 PGRST124
Accept: application/vnd.pgrst.object+jsonanything but exactly one row affected → 406 PGRST116
Terminal window
# Refuse (and roll back) if this would delete more than one row
curl -X DELETE "/api/public/items?name=eq.Widget" \
-H "Prefer: handling=strict, max-affected=1"
# Update exactly one row, returning it as an object
curl -X PATCH "/api/public/items?id=eq.1" \
-H "Content-Type: application/json" \
-H "Accept: application/vnd.pgrst.object+json" \
-H "Prefer: return=representation" \
-d '{"price": 12.99}'

max-affected is only enforced together with handling=strict (PostgREST semantics); without it the preference is ignored and not echoed in Preference-Applied.

Use Prefer: return=representation to get the affected rows in the response:

Terminal window
curl -X POST "/api/public/items" \
-H "Content-Type: application/json" \
-H "Prefer: return=representation" \
-d '{"name": "New Item", "price": 5.99}'

Response (201 Created):

[{"id": 42, "name": "New Item", "price": 5.99, "category": null, "in_stock": true}]

Database functions are called via POST /api/{schema}/rpc/{function_name}:

Terminal window
# Call a function with arguments
curl -X POST "/api/public/rpc/add" \
-H "Content-Type: application/json" \
-d '{"a": 2, "b": 3}'
# Function returning a set (table-like)
curl -X POST "/api/public/rpc/search_items" \
-H "Content-Type: application/json" \
-d '{"query": "widget"}'
# Function with default parameters
curl -X POST "/api/public/rpc/echo_params" \
-H "Content-Type: application/json" \
-d '{"name": "World"}'

Arguments are passed by name ("a" := $1). Values are sent to Postgres as text and cast by the function’s declared parameter types; JSON arrays and objects are sent as their JSON text. A function returning a single scalar responds with the bare value (5, not [{"result": 5}]).

For a function that returns a set of a known table type, the usual query parameters (select, filters, order, limit, offset) on a POST apply to the returned rows.

The overload is chosen by argument names: a candidate must declare every supplied argument and receive every parameter that has no default.

SituationResult
Unknown function, misspelled/unknown argument, or a required argument missing404 PGRST202
More than one overload matches300 PGRST203
  • STABLE / IMMUTABLE functions → also accessible via GET /rpc/{name}?arg=value
  • VOLATILE functions → POST only. A GET/HEAD call is refused with 405 PGRST101, so a link or crawler can’t trigger writes. Volatile calls always run on the primary, never a read replica.

On a GET call, every query parameter except the reserved words (select, order, limit, offset, …) is a named argument, passed through as text (so zip=02134 stays '02134'). An unknown parameter name therefore resolves no overload and is a 404 PGRST202.

Terminal window
curl "/api/public/rpc/add?a=2&b=3"
# 5

A function chooses the HTTP status of its error through the SQLSTATE it raises:

-- 400 Bad Request: plain RAISE EXCEPTION uses SQLSTATE P0001
RAISE EXCEPTION 'insufficient funds' USING HINT = 'Top up first';
-- Any status: PTxyz → HTTP xyz (PostgREST convention)
RAISE EXCEPTION 'payment required' USING ERRCODE = 'PT402';
RAISE EXCEPTION 'not yours' USING ERRCODE = 'PT403';

The response body’s code is the SQLSTATE (P0001, PT402, …) and hint/details carry the exception’s HINT/DETAIL. A PT code that isn’t a valid status (100–599) is a 500. Other SQLSTATEs map as listed under Database errors.

On Postgres a function (or the pre_request hook) can override the response status and add headers through transaction-local settings, as in PostgREST:

PERFORM set_config('response.status', '202', true);
PERFORM set_config('response.headers', '[{"X-Job-Id": "42"}]', true);

The verified JWT’s claims are available as one JSON setting, request.jwt.claims; the per-claim request.jwt.claim.<key> settings are not set:

SELECT current_setting('request.jwt.claims', true)::json->>'sub';

The Prefer header controls response behaviour. Multiple preferences can be combined with commas.

Controls what the response body contains after mutations:

ValueBehaviour
representationReturn the affected rows as JSON
minimalReturn empty body (just status code + headers)
headers-onlyReturn empty body with headers showing affected count
Terminal window
curl -X POST "/api/public/items" \
-H "Prefer: return=representation" \
-d '{"name": "Test"}'

Controls whether and how total count is computed:

ValueBehaviour
exactFull COUNT(*) — accurate but potentially slow
plannedAccepted, but not computed yet: the total stays *
estimatedAccepted, but not computed yet: the total stays *
0-9/1523
curl "/api/public/items?limit=10" -H "Prefer: count=exact"

Controls UPSERT conflict handling (used with POST):

ValueBehaviour
merge-duplicatesON CONFLICT DO UPDATE
ignore-duplicatesON CONFLICT DO NOTHING
ValueBehaviour
strictUnknown preferences or invalid preference values return 400 PGRST122; enables max-affected
lenientUnknown preferences are silently ignored (the default)

Parsed (default or null) and echoed in Preference-Applied, but not yet applied: keys missing from a bulk-insert row currently insert NULL (see INSERT).

Controls transaction end behaviour (requires tx_allow_override = true in config):

ValueBehaviour
commitNormal commit (default)
rollbackRoll back the transaction (useful for testing/dry-run)

Parsed and echoed in Preference-Applied, but not yet applied to the session: the request runs in the database’s configured timezone.

Terminal window
curl "/api/public/events" -H "Prefer: timezone=America/New_York"

Fail the request, and roll it back, when a PATCH/DELETE (or insert) affects more than N rows. Only enforced together with handling=strict; see Mutation guards.

Terminal window
curl -X DELETE "/api/public/items?status=eq.old" \
-H "Prefer: handling=strict, max-affected=10"
# more than 10 matching rows → 400 PGRST124, nothing deleted
ValueBehaviour
multiple-objectsEach top-level body key is a separate named argument (the default, and the only mode)

params=single-object was removed in PostgREST v13 and is treated as an unknown preference (400 PGRST122 under handling=strict).

When the same preference appears twice, the first occurrence wins.


Present on every successful response. The range starts at the request’s offset; the end saturates rather than overflowing on a huge client-supplied offset:

Content-Range: 0-24/* (count not requested)
Content-Range: 0-24/156 (with Prefer: count=exact)
Content-Range: */* (empty result, no count)
Content-Range: */0 (empty result, with Prefer: count=exact)

Set on a single-row insert into a table with a primary key: Location: /api/public/items?id=eq.42.

Present when using cursor pagination and there are results:

X-Next-Cursor: 42
Content-Type: application/json; charset=utf-8

Echoes back the preferences that were parsed (max-affected only when handling=strict, tx only when tx_allow_override is on):

Preference-Applied: return=representation, count=exact

For table reads the body is Postgres’s own json_agg text, forwarded to the client as bytes without being parsed and re-serialized. Numbers reach the client exactly as Postgres rendered them, and the text keeps Postgres’s spacing ({"id": 1, "name": "Widget"}).


The plan_enabled config flag exists, but the REST router does not serve Accept: application/vnd.pgrst.plan+json yet; such a request is answered like a normal read.


Errors follow PostgREST conventions with JSON body and appropriate HTTP status:

{
"code": "PGRST204",
"message": "Column 'nme' not found in 'items'",
"details": null,
"hint": "Did you mean: name?"
}
CodeHTTP StatusMeaning
PGRST000503Database unavailable: pool exhausted (checkout timed out) or unreachable — retryable
PGRST100400Malformed select, filter, order or and=/or=; a reserved parameter given twice; limit/offset on an insert or on a view’s PATCH/DELETE
PGRST101405Volatile function called with GET/HEAD
PGRST102400Invalid request body
PGRST103416Invalid limit/offset; cursor pagination on a table without a primary key
PGRST116406Singular response requested but not exactly one row
PGRST119400Spread (...) on a to-many relationship
PGRST122400Invalid preference (with handling=strict)
PGRST123400Aggregates disabled
PGRST124400max-affected exceeded (transaction rolled back)
PGRST200400Relationship not found
PGRST201300Ambiguous relationship — add a !hint
PGRST202404Function not found, or no overload accepts the argument names
PGRST203300Ambiguous function overload
PGRST204400Column not found
PGRST205404Table/view not found
PGRST300401JWT required but not provided (no anon_role)
PGRST301401JWT invalid, wrong audience, not yet valid, or carries no role claim while no anon_role is set (500 if the configured key itself is unusable)
PGRST302401JWT expired
PGRST404404OpenAPI requested while openapi_mode is disabled
PGV001400Operation not supported by this backend (501 for the unimplemented inspect endpoint)
PGV500500Internal error

When Postgres rejects the statement, code is the SQLSTATE and details/hint carry the server’s DETAIL/HINT. The HTTP status is chosen from the SQLSTATE:

SQLSTATEHTTP StatusMeaning
23505, 23503409Unique / foreign-key violation
23502, 23514400Not-null / check violation
22P02, 22003, 22007400Invalid text, numeric or datetime input
P0001400RAISE EXCEPTION in a function
PTxyzxyzCustom status chosen by a function (e.g. PT402 → 402)
42501403Insufficient privilege
42P01404Undefined table
42883404Undefined function
25006405Write in a read-only transaction
57014504Statement timeout (query canceled)
08xxx503Connection exception
anything else500

With routing.schema_in_path = true:

Terminal window
GET /api/public/users → public schema
GET /api/analytics/events → analytics schema

With routing.schema_in_path = false:

Terminal window
# Read requests
GET /users -H "Accept-Profile: analytics"
# Write requests
POST /items -H "Content-Profile: analytics"

When no profile header is sent, the routing.default_schema is used.


Accept headerResponse format
application/json (default)JSON array
application/vnd.pgrst.object+jsonSingle JSON object; anything but exactly one row is 406 PGRST116 (for mutations, checked before COMMIT and rolled back)
application/vnd.pgrst.plan+jsonQuery execution plan (planned)
text/csvCSV format (planned)

Terminal window
BASE="http://localhost:3000/api/public"
# Create
curl -X POST "$BASE/items" \
-H "Content-Type: application/json" \
-H "Prefer: return=representation" \
-d '{"name": "Laptop", "price": 999.99, "category": "electronics"}'
# → [{"id": 1, "name": "Laptop", "price": 999.99, ...}]
# Read with filter
curl "$BASE/items?category=eq.electronics&order=price.desc"
# Update
curl -X PATCH "$BASE/items?id=eq.1" \
-H "Content-Type: application/json" \
-H "Prefer: return=representation" \
-d '{"price": 899.99}'
# Delete
curl -X DELETE "$BASE/items?id=eq.1"
Terminal window
# First page
RESPONSE=$(curl -sD - "$BASE/items?order=id.asc&cursor_column=id&limit=20")
CURSOR=$(echo "$RESPONSE" | grep -i x-next-cursor | cut -d: -f2 | tr -d ' \r')
# Next page
curl "$BASE/items?order=id.asc&cursor_column=id&cursor_value=$CURSOR&limit=20"

Complex query with embedding and logic filters

Section titled “Complex query with embedding and logic filters”
Terminal window
# Active users with their orders (nested items included)
# who are either admins OR were created this year
curl "$BASE/users?select=id,name,email,orders(id,total,status,items(name,price))&\
active=is.true&\
or=(role.eq.admin,created_at.gte.2026-01-01)&\
order=name.asc&\
limit=50"

The schema cache is loaded at startup and hot-swapped when the database schema changes, so new tables and functions become routable without a restart (Postgres):

NOTIFY pgrst, 'reload schema'; -- or an empty payload, as in PostgREST

kill -USR1 <pgvis pid> triggers the same reload. Bursts of notifications are coalesced, in-flight requests finish on the cache they started with, a failed re-introspection keeps the current cache, and the data cache drops every entry built against the old schema.


pgvis includes a general-purpose pub/sub system backed by Postgres LISTEN/NOTIFY. When enabled (--pubsub-enabled), the REST surface gains additional endpoints:

Terminal window
# Open an SSE stream on a channel
curl -N -H "Accept: text/event-stream" \
http://localhost:3000/pubsub/chat
Terminal window
# Send a message to all subscribers
curl -X POST http://localhost:3000/pubsub/chat \
-H "Content-Type: text/plain" \
-d '{"user":"alice","text":"hello"}'
Terminal window
# List active channels
curl http://localhost:3000/pubsub

See the full Pub/Sub guide for details on configuration, channel allowlists, and cross-instance messaging.