All articles

Filter pg_dump rows to ship a public data tier

7 min read
pg_dump, the PostgreSQL backup tool, exports a whole database without breaking a sweat. The problem was elsewhere: that same backup had to exist in two versions, one anyone can clone and restore locally, another restricted to a handful of authorized people, without duplicating the extraction logic or maintaining two pipelines that drift apart over time. And pg_dump can't filter rows.

The concrete context: an ETL that periodically dumps the state of a live game into a PostgreSQL database (effects, spells, items, and other tables derived from a binary source format). A web-app then restores this backup to populate its own development database. A seemingly simple question: how do you distribute this data to external developers who contribute to the project, without exposing what is not yet ready to be public?

What pg_dump can't filter

pg_dump backs up objects: schemas, tables, sequences. None of its options accepts a WHERE clause. The ones that come close all work on a whole table, never a row:

OptionWhat it filtersWhy it falls short
--exclude-table-dataall of a table's dataall or nothing: the table loses its publishable rows too
pg_restore -L (archive format)the objects to restorea table is restored in full or not at all
--filter (PostgreSQL 17)objects listed in a filestill at the table or schema level
COPY (SELECT ... WHERE ...)the result of a queryone query per table, outside the backup: the extraction logic is duplicated

The need sits at the row level: in the items table, keep the ones that are ready and drop the rest. So the filtering happens on the backup, after pg_dump, not during it.

One source dump, two flavors

The answer isn't two separate pipelines but a single complete extraction of the database, followed by a derivation.

ETL
PostgreSQL database
pg_dump
Full dump
private flavor
allowlist
Filtered dump
public flavor
release
GitHub releases
one per flavor
The private flavor is the full backup. The public flavor is derived from it, never extracted separately.

The important point is the direction of the filtering. When a new category of items appears in the database (a new type of cosmetic, a new content set), it doesn't automatically land in the public flavor.

Blocklist

The list says "here's what needs to be removed". An oversight leaks: new, unlisted content ships automatically.

Allowlist (chosen)

The list says "here's what's allowed out". Everything else is excluded: new content stays out until someone explicitly adds it.

That's the direction of error you want when the stakes are not shipping unfinished data.

Filter pg_dump rows in plain-text format

Without a format option, pg_dump's output is a SQL file (the plain format, the default one) that psql can replay into an empty database. Each table's data arrives as a COPY ... FROM stdin block: one line per row, then \. to close the block. That's what keeps the filter simple: a script walks through the file and, inside the blocks of the tables concerned, only keeps the rows whose identifier is on the allowlist.

dump.sql (simplified excerpt)
sql
COPY public.item (id, name) FROM stdin;
1201    Published item
1202    Unannounced item 
1203    Published item
\.

Everything else passes through untouched: the schema, the sequences, the non-sensitive tables. The filtered file keeps the shape of the original and restores the same way: the restore command doesn't need to know anything changed. What matters isn't this particular script but the idea that filtering happens downstream of a single source backup, never by duplicating the extraction query.

GitHub releases as a poor man's CDN

Once the two files exist, they need to be distributed. No need for a file server, a dedicated CDN or an object storage bucket: GitHub releases do the job for this volume and this publishing frequency. Each release is tagged with the data source version and carries a single asset: the compressed backup.

The two flavors differ on two axes: the target repository and a tag suffix.

Public flavor

Tag data-v<version>, main repository <org>/<public-project>, open to everyone.

Private flavor

Tag data-v<version>-private, restricted repository <org>/<private-data-project>, marked as prerelease.

The prerelease flag isn't cosmetic: it signals that this release isn't meant for general consumption, even for someone who sees the repository by mistake or out of curiosity. It's a second barrier, independent of the access rights on the repository itself.

The contract on the consumer side

On the web-app side, the seed command that downloads these backups and restores them into a local database has no notion of "confidential". It knows a flavor and a repository, both supplied through environment variables, with default values pointing to the public case:

.env (web-app)
bash
SEED_FLAVOR=public                 # or "private"
SEED_REPO=<org>/<public-project>   # or the restricted repo

Resolving the right release then means querying the target repository's releases API and filtering by tag pattern (with the suffix for private, without it for public). An external developer who sets no variable gets, by construction, the public flavor of the public repository. A developer authorized on the private tier has to point explicitly at the other repository and the other flavor, with an authentication token that only exists for the people who need it.

The contract is clean because it relies on no hidden client-side logic: the whole authorization decision lives in "who has access to which repository and which token", not in the seed command's code. The resolution code is identical for both tiers, only the configuration changes.

Why separate rather than open everything

The natural temptation would be to publish everything and let everyone filter their own database client-side. That doesn't work here, because the reason for exclusion isn't volume or relevance, it's content maturity. A database built from raw game files inevitably contains artifacts that were never meant to ship: content still being finalized, test entries left by internal tools, elements removed but not yet cleaned up from the source. None of that has any value for an external contributor.

Watch out

Publishing one of these elements too early can have consequences (an unwanted announcement, incomplete content visible ahead of time) that no client-side filtering can fix after the fact.

Beyond game data

This pattern goes well beyond games. Whenever a pipeline produces data of which part must stay restricted, the same structure applies, and the contract between producer and consumer comes down to a handful of environment variables: which flavor, which repository, which tag suffix. It stays testable and reusable, without ever coupling the consumer's code to the notion of confidentiality.

The pattern in 4 points

  • One extraction: the private flavor is the full backup, the public flavor is derived from it.
  • pg_dump filters tables, not rows: the row-level pass runs on the SQL file, downstream, before any restore.
  • An allowlist rather than a blocklist: forgotten content stays private instead of leaking.
  • Versioned releases, with a prerelease flag on the private tier, serve as both channel and history.

Reactions

Click an icon to react.

Comments

No comment · no reply

Add a comment

No discussion yet.
Be the first to leave a comment.

Secrets

0/20. 20 to go.

Finish a tier to make the next one’s hints clear.

Terminal