The problem
A BI tool usually talks to one database at a time. Requests to change this are among the most popular in Metabase's tracker: join across multiple databases has 2110 reactions and has been open since 2016, and a REST API driver has 889. Metabase's own guide lists a data warehouse, foreign data wrappers, views and federated query engines as workarounds. These work, but they need either a Trino cluster or a PostgreSQL server you may change, plus setup per source.
Projects that put a PostgreSQL protocol in front of DuckDB exist as well. PostHog discontinued duckgres in October 2026, and buenavista has not changed since December 2024. The case I wanted to cover is small: one process, started next to Metabase by a team that has no data platform.
How it works
pgfederate reads one YAML file. Each
source becomes a schema: PostgreSQL, MySQL and SQLite are attached through
DuckDB's extensions, files are read at query time, and JSON APIs are loaded on a
schedule with pagination, retries and flattening of nested objects. A
relations entry is shown to clients as a foreign key, so Metabase's query
builder offers joins across sources without SQL.
Three parts took most of the work:
- The server implements PostgreSQL protocol 3: SCRAM login, prepared statements, portals, cancel requests, and results in text and binary format.
- Clients discover tables through
pg_catalogandinformation_schema. DuckDB's own views describe DuckDB, so pgfederate keeps separate tables that show one database with one schema per source, and rebuilds them after each refresh. - Every statement is parsed with sqlglot before it runs. Only reads of exposed
tables pass, DuckDB functions such as
read_csvandqueryare refused, and DuckDB itself runs with external access disabled.
Example
The quick start joins a SQLite database and a CSV file through psql:
pgfederate=> select c.name, sum(i.amount) as billed
from shop.customers c join billing.invoices i on i.customer_id = c.id
group by c.name order by billed desc;
name | billed
--------+--------
Acme | 30.5
Globex | 5.25
(2 rows)
This is the output of the end-to-end run in CI, where the real Metabase talks to the gateway through its PostgreSQL driver:
ok: metabase synced every source
ok: metabase sees the cross-source foreign key
ok: native query joins Postgres, MySQL and REST: [['Acme', 53.5, 2], ['Globex', 50.0, 1], ['Initech', 9.5, 0]]
ok: query builder join across MySQL and Postgres: {'Acme': 4, 'Globex': 2, 'Initech': 1}
ok: month bucketing: [['2026-03-01T00:00:00Z', 2], ['2026-04-01T00:00:00Z', 2]]
ok: filter with a bound parameter
How it is tested
The test suite runs psycopg in text and binary mode, asyncpg and psql against a
real server, and replays the catalog queries of Metabase, pgjdbc and Grafana,
which I took from their source code. About 35 tests try writes and escapes such
as reading files or attached databases directly. PostgreSQL and MySQL sources run
against real servers in CI, including a check that filters appear in the scan
plan. A second CI job starts a compose stack and drives Metabase and Grafana
through their HTTP APIs.
The first CI run found no gateway bug, only a mistake in my test query, which
multiplied rows through a join. A local run with a wider set of queries then
found a real one: substring(x from 'pattern') is a regex search, and Metabase
binds the pattern as a parameter. About 80 query builder cases now run in CI.
Limits
pgfederate is read-only and runs in one process, so it does not replace Trino on
large data. Filters are pushed down to PostgreSQL and MySQL, aggregations are
not, and a cross-source join pulls the rows it needs into DuckDB. REST data is a
snapshot from the last refresh. SQL that DuckDB cannot run fails even if it is
valid PostgreSQL, and Metabase's host and domain expressions fail because
they use regex lookaround. DECLARE and FETCH are refused, so named cursors in
psycopg do not work. There are no per-user query quotas. Only the clients named above are
tested.
The code is at github.com/Jwrede/pgfederate.