Attach a Postgres database
DuckDB can ATTACH a Postgres database and query its tables as if they were local. The challenge at generation time is type introspection: SQG needs the Postgres schema to type-check your queries, but you don’t want it reaching out to production.
A postgres source solves this declaratively. During generation SQG:
- starts a throwaway Postgres testcontainer,
- applies your schema to it natively (true Postgres types),
- attaches it into the in-memory DuckDB introspection connection,
- type-checks your queries, then tears the container down.
The container, the schema, and the synthesized ATTACH are never emitted into the generated code. At runtime your application attaches the real production database under the same alias — the BASELINE “schema owned elsewhere” contract.
Declare a postgres source
Section titled “Declare a postgres source”-
Declare the source in
sqg.yamlwithtype: postgres. Thenameis the DuckDB attach alias. -
Describe its schema with
BASELINEblocks tagged:source=<name>. These are valid Postgres DDL and run natively against the container — they are not run against DuckDB and not emitted as migrations. -
Write queries against the attached catalog (
<name>.public.<table>).
-- Schema of the external production database. Applied natively to the throwaway-- container during generation; not emitted into the generated code.
-- BASELINE prod_orders :source=prodCREATE TABLE orders ( id BIGINT PRIMARY KEY, customer TEXT NOT NULL, total NUMERIC(10, 2), created_at TIMESTAMP);
-- QUERY getOrder :one@set id = 1SELECT id, customer, total, created_at FROM prod.public.orders WHERE id = ${id};
-- QUERY listOrdersSELECT id, customer FROM prod.public.orders ORDER BY id;version: 1name: orderssql: - files: - queries.sql gen: - generator: typescript/duckdb output: ./src/generated/sources: - name: prod type: postgres # image: postgres:16-alpine # optional, this is the defaultSQG starts the container, runs the :source=prod schema into it, attaches it as prod, and the queries type-check with real Postgres types. The generated SQL reads ... FROM prod.public.orders ... verbatim — the same text that runs against production at runtime.
Attach the real database at runtime
Section titled “Attach the real database at runtime”Attaching the production database is your application’s responsibility — but SQG generates a typed helper for each postgres source so you don’t hand-write the ATTACH. The helper is named attach<Source> (here attachProd) and attaches under the same alias the source used. Call it once, then run queries:
import { DuckDBInstance } from '@duckdb/node-api';import { Orders } from './generated/queries';
const db = await DuckDBInstance.create();const conn = await db.connect();
const orders = new Orders(conn);await orders.attachProd(process.env.DATABASE_URL!); // ATTACH '…' AS prod (TYPE postgres)
const order = await orders.getOrder(42n);try (Connection conn = DriverManager.getConnection("jdbc:duckdb:")) { Orders orders = new Orders(conn); orders.attachProd(System.getenv("DATABASE_URL")); // ATTACH '…' AS prod (TYPE postgres)
Optional<Orders.GetOrderResult> order = orders.getOrder(42L);}orders = Orders(conn)orders.attach_prod(os.environ["DATABASE_URL"]) # ATTACH '…' AS prod (TYPE postgres)
order = orders.get_order(42)The DSN never goes through SQG — you pass it in from your application’s own config/environment, so dev and prod differ by environment alone with identical generated code. The string is interpolated into the ATTACH (DuckDB does not accept a bind parameter there), so treat it as trusted configuration.
The helper runs INSTALL postgres; LOAD postgres; before the ATTACH (both idempotent), so it works even when DuckDB extension autoloading is disabled. INSTALL is a no-op once the extension is present; in a fully offline runtime, install the postgres extension into DuckDB ahead of time.
Need more control than the helper offers (read-only, a DuckDB secret, or ATTACH '' reading PGHOST/PGPORT/PGUSER/PGPASSWORD/PGDATABASE from the environment)? Skip the helper and run your own ATTACH … AS prod (TYPE postgres) — just keep the prod alias.
What must line up
Section titled “What must line up”- Same catalog alias — the source
nameand your runtimeATTACH … AS <name>must match; the generated SQL bakes in<name>.public.<table>literally. - Schema-qualified path — Postgres tables live under
public, so queries readprod.public.<table>.
Sources are scoped to the blocks that use them
Section titled “Sources are scoped to the blocks that use them”A postgres source is project-level, but it only affects the sql blocks whose queries actually reference it (via a :source=<name> BASELINE or a <name>. catalog reference). Adding a source used by one block leaves every other block’s generated code byte-identical — unrelated generators don’t get an attach<Source> helper, and SQG won’t start a container while processing them. Put differently: a generator gets the helper only if its SQL talks to the source.
Introspect an existing database instead of a container
Section titled “Introspect an existing database instead of a container”Set url on a postgres source to introspect against an existing database (a dev/staging Postgres) instead of a throwaway container:
sources: - name: prod type: postgres url: $DATABASE_URL # literal DSN, or $ENV_VAR / ${ENV_VAR} read from the environmentWith url set, SQG:
- does not start Docker — useful in CI or any Docker-less environment, and ~2–3s faster per run;
- uses the database’s real, live schema for introspection, so it cannot drift from a hand-maintained mirror;
- ignores the source’s
:source=<name>BASELINE blocks (the live schema wins). You can drop them, or keep them as documentation.
A url of the form $VAR or ${VAR} is read from the environment so credentials stay out of sqg.yaml; any other value is treated as a literal DSN. The generated runtime helper is unchanged — only generation-time introspection differs. Point url at a database that mirrors production’s schema (a migrated dev DB), never at production itself.
Lower-level alternative: a manual BASELINE attach
Section titled “Lower-level alternative: a manual BASELINE attach”If you don’t want SQG to manage a container — for example to keep generation Docker-free in CI — you can attach a stand-in catalog yourself in a plain BASELINE block and recreate the schema in it:
-- BASELINE attach_prodATTACH ':memory:' AS prod;-- BASELINE prod_schemaCREATE SCHEMA prod.public;-- BASELINE prod_ordersCREATE TABLE prod.public.orders (id BIGINT, customer VARCHAR, total DECIMAL(10,2), created_at TIMESTAMP);This needs no Docker and runs anywhere, but you get DuckDB-native types during introspection rather than real Postgres types, and you maintain the schema in DuckDB DDL. Prefer a postgres source when type fidelity matters. To stay Docker-free and keep real Postgres types, give the postgres source a url pointing at a database you manage — SQG introspects against it directly (a reachable database is then required at generation time).