Dawloom
All posts

Why Postgres is our default database

Dawloom engineering5 min read

When a new project starts, the database question usually gets asked like it’s a hard problem. It mostly isn’t. Unless something specific rules it out, we start with PostgreSQL, and we keep it unless the project proves it needs something else. Not because Postgres is trendy. Because a single well-run instance covers more ground than people expect, and every extra service you add is one more thing to patch, back up, and page someone about at 2am.

FurnitureAxis, our multi-tenant SaaS for furniture retailers, runs on Postgres with a large Prisma schema. That is not a small schema. It’s the kind of size where you’d expect a document store bolted on for the messy data, a search cluster for the catalog, and a Redis instance for background jobs. Postgres handles all three roles itself, and here’s the part that’s worth spelling out: why.

PostgreSQL at the center, with four panels around it showing what it replaces: documents instead of MongoDB, search instead of Elasticsearch, queues instead of Redis, and tenant isolation instead of trusting every query

Documents, without a document database

Relational schemas are good at data that has a fixed shape. Real products don’t always give you that. A vendor’s product feed has fields that vary by category, a webhook payload has whatever the third party decided to send that week, an audit log entry has different keys depending on what happened. The old answer to that was a separate document database sitting next to the relational one, with its own connection pool and its own operational quirks.

Postgres’s jsonb type stores JSON in a decomposed binary form rather than as text, so it doesn’t need to reparse the document on every read. You can index it with GIN, query nested keys, and use containment operators like @> to ask “does this document have these fields” without pulling every row into application code first. Prisma maps its Json field directly to jsonb on Postgres, so a model with a handful of predictable columns and one flexible jsonb column is a normal, supported pattern in production schemas.

Search, without a search service

Postgres ships with full-text search built in. tsvector holds a normalized, indexed representation of a document, tsquery holds the parsed search terms, and the @@ operator matches one against the other with stemming and stop-word handling already done for you. For an admin panel search box, an internal tool, or a catalog that doesn’t need typo tolerance or faceted filtering, that’s often the whole feature: no separate service, no index to keep in sync, because the search index lives in the same table as the data it’s searching.

It stops being enough once you need instant-as-you-type relevance ranking, typo tolerance, or filtering across many facets at once, the things a dedicated search engine is built around. FurnitureAxis is a real example on both sides of that line: it runs on Postgres, and it also has a C# background worker that syncs its vendor catalog into Algolia on a schedule, alongside the same sync into Shopify. Full-text search was not the right tool for catalog browsing with facets and typo tolerance across a large multi-vendor catalog, so that part gets a dedicated service. The rest of the app didn’t need one.

Queues, without a queue service

A job queue is a table with a status column and a locking strategy, until it isn’t. Postgres has what you need for the “until it isn’t” part built in. The SKIP LOCKED clause on a SELECT ... FOR UPDATE tells Postgres to skip any row another transaction already has locked instead of waiting for it, so multiple workers can pull from the same table without stepping on each other or piling up behind a lock:

UPDATE jobs
SET status = 'processing'
WHERE id = (
  SELECT id FROM jobs
  WHERE status = 'pending'
  ORDER BY created_at
  FOR UPDATE SKIP LOCKED
  LIMIT 1
)
RETURNING *;

That covers a lot of background-job use cases without adding Redis or a dedicated queue service to the stack, one less thing to deploy, monitor, and keep in sync with the data it’s acting on. It’s not the right fit for very high job volumes or where you need features like delayed retries with backoff built in at the infrastructure level, but plenty of apps never hit that ceiling.

Tenant isolation, without a proxy layer

For a multi-tenant SaaS product, the standing risk is a query that forgets its WHERE tenant_id = ... clause and returns another customer’s rows. Row-level security moves that check into the database itself. You enable it on a table, write a policy with CREATE POLICY, and Postgres filters every query against that table by the policy, no matter what the application code did or didn’t remember to add. It sits underneath the application-level tenant scoping rather than replacing careful query code, and it’s one we weigh seriously on any multi-tenant build where a data leak between customers isn’t an acceptable failure mode.

When we don’t default to Postgres

None of this makes Postgres the right answer for everything. Pure caching and rate limiting are a better fit for Redis, which is built around in-memory speed rather than durability guarantees. Heavy analytical queries over huge event volumes usually want a columnar store built for scanning, not a row store built for transactions. And once search needs typo tolerance, faceting, and relevance tuning at real scale, as FurnitureAxis’s catalog does, a dedicated engine earns its place next to Postgres rather than instead of it.

The pattern we keep coming back to is: start with one database, and only add a second system once a specific, named requirement can’t be met by the first one. That’s cheaper to run, easier to reason about, and a lot easier to hand off to whoever maintains the project after us. If you’re scoping a new build and want an honest read on what your data actually needs, that’s a web development conversation worth having before the stack gets decided by habit. We build most of that stack on Next.js for the application layer, in retail and operations-heavy products where the data model tends to grow past what a single flat schema can hold, which is exactly where Postgres earns its keep.

Got something to build?

Tell us what you need. An engineer replies, not a sales team.

Search the whole site