pgfire
Drop-in Firestore API, backed by Postgres — migrate an existing Firestore codebase to PostgreSQL without rewriting every .collection().document() call site.
pip install pgfire
pgfire keeps the chainable, familiar developer experience of the Google Cloud Firestore SDK (.collection().document().where()) running on plain PostgreSQL tables with native JSONB, in ~400 lines.
Scope & intention
pgfire is not a general-purpose ORM for new projects. It's a hyper-targeted tool for one real problem: migrating an existing Firestore-backed codebase to PostgreSQL with minimal call-site rewrites, when hundreds of existing .collection().document().get() calls make a wholesale relational redesign too slow and too risky to do up front.
If you're starting something new, use SQLAlchemy or Prisma instead — they're more capable, more battle-tested, and have real tooling (type-generated clients, migrations, connection pooling) that this project deliberately does not try to reinvent. If you're doing a surgical, incremental cutover of an existing Firestore app, pgfire is built for exactly that.
How it actually works
Storage model: one Postgres table per Firestore collection. Every table has id TEXT PRIMARY KEY and data JSONB. Any field you filter/order on often can be promoted to a real GENERATED ALWAYS AS (data->>'field') STORED column for a real index — pgfire detects which columns exist via information_schema and uses them automatically; anything not promoted falls back to a data->>'field' JSONB lookup, so it works against a bare (id, data) table too.
Subcollections (users/{uid}/moods/{uid}) map to a separate table named by joining the path with __ (users__moods), with a parent_id column holding the parent document's id. This composes at any depth automatically — there's no special-casing per collection, calling .document(id).collection(name) again just repeats the same rule on whatever table you're currently on. As in Firestore, a child id only has to be unique within its parent, so subcollection tables need PRIMARY KEY (parent_id, id); reads, writes and deletes are scoped to the parent.
Plain (non-generated) timestamp columns — e.g. created_at, session_date — can't be GENERATED (Postgres rejects text->timestamp casts there; the cast isn't IMMUTABLE). pgfire's .set() populates any such column itself from a matching key in the document you pass in, so you don't have to maintain it by hand.
What's implemented
db.collection(name)/.document(id)— chainable refs, matching Firestore's shape.get()/.set(data, merge=False)/.update(data)/.delete()—update()raisespgfire.NotFoundon a missing document, like Firestore- Field transforms:
Increment,ArrayUnion,ArrayRemove,SERVER_TIMESTAMP,DELETE_FIELD— pgfire's own, or google-cloud-firestore's (recognized by duck-typing, no dependency), sofirestore.Increment(1)code runs unchanged. Applied atomically in the same SQL statement as the write. .where(field, op, value)with==,!=,<,<=,>,>=,in— chainable,AND-combined.order_by(field, direction),.limit(n),.select([fields])(projection).stream()(generator) /.get()(list) on a query.count()—COUNT(*)server-side, no row fetch.aggregate(**named_filters)— multipleCOUNT(*) FILTER (WHERE ...)conditions in a single round trip; each value is(field, op, value)or a raw SQL boolean expression string- Subcollections, composable to any depth
.batch()— atomic, all-or-nothing like a FirestoreWriteBatchdb.transaction()— aBEGIN ... COMMITcontext manager (row locking is on you — take it out yourself withSELECT ... FOR UPDATEinside the block)
Semantics worth knowing
- Timestamps are stored as UTC instants, like Firestore: an aware
datetimeis converted to UTC; a naive one is treated as UTC. They read back as ISO-8601 strings ("2026-10-20T10:00:00+00:00"), notdatetimeobjects — parse them where you do arithmetic. - Numbers compare numerically in
where()/order_by()on JSONB fields (10 > 9,5 == 5.0); strings compare as text. - Autocommit outside
transaction(): a long-lived client never sits "idle in transaction", and one failed statement can't poison later ones. The client reconnects automatically if its connection was closed (server restart, failover).
What's not implemented
array_contains/ array-membership filterscollection_group()queries,Maximum/Minimumtransforms, transforms nested inside a map field- Cursor-based pagination (
start_after/start_at) — use.limit()with an.order_by()on an indexed column instead .select()on nested (dotted) field paths- Connection pooling —
PostgresClientholds one plainpsycopg2connection. Put a pooler (AWS RDS Proxy, PgBouncer) in front of it for any real concurrent traffic. - Compile-time type checking / a generated client —
pgfirecoerces values for JSONB comparisons at runtime (seepgfire/utils.py); there's no static schema to check your calls against. Lean on integration tests. - Foreign-key enforcement — the
datapayload isn't validated against anything; nothing stops a dangling reference the way a real FK would.
Audit your own call sites against this list before assuming parity on anything not explicitly covered above.
Quick start
from pgfire import PostgresClient
db = PostgresClient("host=localhost port=5432 dbname=mydb user=me password=secret sslmode=require")
# Document access - same shape as the Firestore SDK
ref = db.collection("users").document("usr_12345")
ref.set({"email": "a@b.com", "user_type": "patient"})
snap = ref.get()
if snap.exists:
user = snap.to_dict()
# Queries
admins = db.collection("users").where("user_type", "==", "admin").where("is_demo", "==", False).get()
# Server-side aggregation - no row fetch, no Python-side counting loop
counts = db.collection("users").where("user_type", "==", "patient").aggregate(
total="TRUE",
onboarded=("status", "==", "onboarded"),
)
# -> {"total": 30166, "onboarded": 30114}
# Subcollections
invite_ref = db.collection("companies").document("co_1").collection("invites").document()
invite_ref.set({"email": "invitee@example.com", "used": False})
Production trade-offs
| Dimension | pgfire's behavior |
What you need to add |
|---|---|---|
| Connection pooling | None — one plain connection per PostgresClient |
AWS RDS Proxy or PgBouncer in front |
| Type safety | Runtime coercion, no generated client | Integration tests on every call site you port |
| Data integrity | No foreign-key enforcement | A monitoring/cleanup job for orphaned references, if you need one |
| Concurrency | Synchronous, one connection | Fine behind a pooler with standard sync workers (e.g. Gunicorn); don't share a client across concurrent requests without one |
| Realtime | No onSnapshot-equivalent |
Keep any feature depending on Firestore's live listeners on Firestore, or build your own realtime layer (websockets/SSE) before migrating it |
Testing
pip install -e ".[test]"
pytest
Tests run against a real Postgres instance — set PGFIRE_TEST_DSN to a connection string for a disposable database (CI spins one up as a service container; see .github/workflows/test.yml).
License
MIT.
Metadata
Release files for pgfire 0.1.4
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pgfire-0.1.4.tar.gz | 25.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pgfire-0.1.4-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 45.5 kB
Release files / pgfire-0.1.4.tar.gz
| Download URL | pgfire-0.1.4.tar.gz |
|---|---|
| Size | 25.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
bdfd5bc44cb15663f13dc8dd7d441649ee1bbd47239acf3d922c723fa75da77f
|
|
BLAKE2b-256 checksum How to use checksums |
9013c9e651d5649ae83c985a2a50e30fe4a709ceb22941ef5bb15354aabed8e5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 8, 2026.
Transparency logRelease files / pgfire-0.1.4-py3-none-any.whl
| Download URL | pgfire-0.1.4-py3-none-any.whl |
|---|---|
| Size | 19.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
fc02f1a48cf9ffc1fadcf176c7a6097d623cc6225c6f75cbf5a8de19d7fc1d7f
|
|
BLAKE2b-256 checksum How to use checksums |
eba804224dd999b3bb6e4a2feb340264ae1f7f0f47946a7bea842f8021db48ac
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 8, 2026.
Transparency log