Install

# Single binary
PR_VER=12.2.0
curl -L -o /tmp/postgrest.tar.xz \
    "https://github.com/PostgREST/postgrest/releases/download/v${PR_VER}/postgrest-v${PR_VER}-linux-static-x86-64.tar.xz"
tar -xJf /tmp/postgrest.tar.xz -C /tmp
sudo mv /tmp/postgrest /usr/local/bin/

# Or via Nix / Brew
nix-env -iA nixpkgs.postgrest
brew install postgrest

# Or as a container
docker run --rm postgrest/postgrest:v12.2.0

Configuration

Two-line config:

# /etc/postgrest.conf
db-uri = "postgresql://authenticator:pw@127.0.0.1:5432/mydb"
db-schemas = "api"
db-anon-role = "web_anon"

# JWT for authenticated requests
jwt-secret = "<a-long-random-key>"
server-port = 3000

Two database roles matter:

  • authenticator — the role PostgREST itself logs in as. NO PRIVILEGES on data tables — only the right to switch into the per-request role via SET ROLE.
  • web_anon — the role PostgREST switches into for unauthenticated requests.
-- In the database
CREATE ROLE web_anon NOLOGIN;
CREATE ROLE authenticator NOINHERIT LOGIN PASSWORD 'pw';
GRANT web_anon TO authenticator;

The first table

CREATE SCHEMA api;

CREATE TABLE api.todos (
    id          bigserial PRIMARY KEY,
    title       text NOT NULL,
    done        boolean NOT NULL DEFAULT false,
    created_at  timestamptz NOT NULL DEFAULT now()
);

GRANT USAGE  ON SCHEMA api          TO web_anon;
GRANT SELECT, INSERT, UPDATE, DELETE ON api.todos TO web_anon;
GRANT USAGE, SELECT ON SEQUENCE api.todos_id_seq TO web_anon;

Start PostgREST:

postgrest /etc/postgrest.conf

The API is now live:

# List
curl http://localhost:3000/todos

# Filter
curl 'http://localhost:3000/todos?done=eq.false&order=created_at.desc&limit=10'

# Insert
curl -X POST http://localhost:3000/todos \
    -H 'Content-Type: application/json' \
    -d '{"title": "Buy milk"}'

# Update
curl -X PATCH 'http://localhost:3000/todos?id=eq.42' \
    -H 'Content-Type: application/json' \
    -d '{"done": true}'

# Delete
curl -X DELETE 'http://localhost:3000/todos?id=eq.42'

Filtering, ordering, pagination, partial responses, and resource embedding are all in the query string. No routes were written.

The query operators

?status=eq.active           # equals
?price=gt.10&price=lt.100   # greater/less than
?status=in.(active,pending) # IN
?title=ilike.*book*         # case-insensitive LIKE
?tags=cs.{tag1,tag2}        # array contains
?metadata->>type=eq.foo     # JSONB field

?select=id,title,user(name) # column selection + foreign-table embed
?order=created_at.desc
?limit=20&offset=40

# Range header style pagination too:
curl -H 'Range: 0-19' -H 'Prefer: count=exact' .../todos

Embedding related rows

Foreign keys in the schema become embeddable resources:

CREATE TABLE api.users (id bigserial PRIMARY KEY, name text);
ALTER TABLE api.todos ADD COLUMN user_id bigint REFERENCES api.users(id);
curl 'http://localhost:3000/todos?select=id,title,user:users(id,name)&limit=5'

# Or top-down: every user with their open todos
curl 'http://localhost:3000/users?select=id,name,todos(*)&todos.done=is.false'

Single round-trip, joined at the database, returned as nested JSON.

Functions as RPC

Pure SQL or PL/pgSQL functions in the exposed schema become POST endpoints:

CREATE FUNCTION api.complete_all_for_user(p_user bigint)
    RETURNS bigint LANGUAGE sql AS $$
    UPDATE api.todos SET done = true WHERE user_id = p_user RETURNING count(*);
$$;
GRANT EXECUTE ON FUNCTION api.complete_all_for_user(bigint) TO web_anon;
curl -X POST http://localhost:3000/rpc/complete_all_for_user \
    -H 'Content-Type: application/json' \
    -d '{"p_user": 42}'

Authentication: JWT

PostgREST doesn't issue JWTs — it validates whatever signed tokens you give it. Pair it with an OIDC provider (Authentik in that tutorial works perfectly) that signs JWTs with the shared secret.

A signed JWT carrying a role claim switches the database role per request:

// JWT payload
{
  "role": "web_user",
  "user_id": 42,
  "exp": 1727900000
}
curl http://localhost:3000/todos \
    -H 'Authorization: Bearer <jwt>'
# Now executed as web_user instead of web_anon

Authorization: row-level security

Postgres RLS plus a per-request role gives database-enforced per-row permissions. Users can only see their own todos:

CREATE ROLE web_user;
GRANT web_user TO authenticator;
GRANT USAGE ON SCHEMA api TO web_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON api.todos TO web_user;

ALTER TABLE api.todos ENABLE ROW LEVEL SECURITY;

CREATE POLICY todos_isolation ON api.todos
    USING (user_id = current_setting('request.jwt.claims', true)::json->>'user_id' = id::text)
    WITH CHECK (user_id = current_setting('request.jwt.claims', true)::json->>'user_id' = id::text);

(Adjust the cast/expression as needed; the canonical helper auth.uid() in Supabase wraps that pattern.)

Now a request with role web_user and JWT user_id=42 can only see/edit rows where user_id = 42. No matter how the API is queried — via PostgREST, psql with that role, or another tool — the database enforces the isolation. Authorization moves from "be careful in every API route" to "one CREATE POLICY, applied to everyone."

Worth knowing

  • PostgREST is stateless. Scale horizontally by running multiple instances behind a load balancer.
  • OpenAPI / Swagger is exposed at / by default — the API documents itself from the schema.
  • For complex business logic, write SQL functions; for write-side validation, use CHECK constraints + RAISE EXCEPTION in PL/pgSQL.
  • For long-running operations, use database triggers + LISTEN/NOTIFY, or pair PostgREST with a separate worker that reads a job table.
  • Supabase is essentially "PostgREST + GoTrue auth + a hosted UI" — the architectural ideas are the same; the convenience layer is what they charge for.

When PostgREST is the wrong tool

  • APIs that are mostly orchestration of external services — PostgREST exposes one Postgres database, not a workflow engine.
  • APIs that need responses shaped fundamentally differently from the database structure — you can paper over it with views and functions, but at some point a hand-written API is more honest.
  • Anywhere the team is not comfortable writing SQL — the leverage all comes from the database; teams uncomfortable with relational schemas will not enjoy this.

For the use cases it fits — internal tools, admin panels, mobile-app backends with relational data, the typical SaaS CRUD layer — PostgREST collapses a class of code that's usually rewritten twice into a 50-line SQL schema.