Postgres for Everything: What the 'Just Use Postgres' Hype Means for Your .NET Stack

A front-page HN piece argues PostgreSQL can be your queue, cache, search engine and vector store all at once. Here is how that choice plays out in a real .NET codebase, with working C#, and where the line actually is.

Jean-Pierre Broeders

Freelance .NET Developer

August 20, 202612 min. read
Postgres for Everything: What the 'Just Use Postgres' Hype Means for Your .NET Stack

An article titled "PostgreSQL for Everything" hit the top of Hacker News this week. The pitch is simple. You do not need Kafka, Redis, Elasticsearch or MongoDB, because Postgres can do all of it. Queue, cache, full-text search, JSON documents, time series, even vectors for your AI features. All in the one database you are already running.

The comments went exactly the way you would expect. Half the thread says "finally, someone said it." The other half posts a graph of the moment their Postgres-as-a-queue fell over under load. Both camps are right, and that is precisely why the topic is worth writing about.

I have been building .NET systems on Postgres for years. So let me make this more concrete than a bullet list of extensions. What does "Postgres for everything" actually mean once you have to write it in C#, and where does the rope snap?

Why this keeps coming back

The argument is not new. What has changed is the arithmetic around it. Every extra piece of infrastructure you run costs you something: a Redis instance, a Kafka cluster, an Elasticsearch node. That is money, but mostly it is operational attention. Somebody has to patch those things, monitor them, back them up, and kick them back to life at three in the morning.

For a small to mid-sized team, that is the real bill. Not the license, but the human who wakes up at night. If you are already running Postgres and already backing it up properly, then every workload you can keep inside it is one fewer system you have to understand. That is the heart of the story, and it holds.

The trap is that "Postgres can do it" and "Postgres is good at it" are not the same sentence. Postgres can run Tetris in a recursive query. You do not want that in production. So the question is per workload: where is it genuinely good enough, and where are you starting to kid yourself?

The queue: Postgres wins here almost every time

This is the strongest example, and it is also the one people most often get backwards. They reach for Kafka because "we might scale one day," and eighteen months later they are running a cluster for a workload of two hundred messages a minute.

The trick in Postgres is called SKIP LOCKED. Since version 9.5 you can claim a row without a second worker blocking on that same row. The second worker simply skips the locked row and grabs the next one. That is all you need to build a concurrent job queue out of a single table.

The table:

CREATE TABLE jobs (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payload     jsonb NOT NULL,
    status      text NOT NULL DEFAULT 'queued',
    attempts    int  NOT NULL DEFAULT 0,
    locked_at   timestamptz,
    created_at  timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX idx_jobs_queued ON jobs (created_at) WHERE status = 'queued';

That partial index matters. Your queue query only ever looks at rows with status queued, so the index only has to contain those rows. The moment a job finishes it drops out of the index, which keeps the index small no matter how much history you keep around.

The claim, from a .NET worker using Npgsql:

public async Task<Job?> ClaimNextAsync(NpgsqlConnection conn, CancellationToken ct)
{
    const string sql = """
        UPDATE jobs
        SET status = 'processing', locked_at = now(), attempts = attempts + 1
        WHERE id = (
            SELECT id FROM jobs
            WHERE status = 'queued'
            ORDER BY created_at
            FOR UPDATE SKIP LOCKED
            LIMIT 1
        )
        RETURNING id, payload;
        """;

    await using var cmd = new NpgsqlCommand(sql, conn);
    await using var reader = await cmd.ExecuteReaderAsync(ct);

    if (!await reader.ReadAsync(ct))
        return null;

    return new Job(reader.GetInt64(0), reader.GetString(1));
}

One statement. The subquery picks the oldest available job, locks it, and the surrounding UPDATE flips it to processing right away. Run this from ten workers at once and each worker grabs a different job. No double processing, no separate broker.

Where it snaps: throughput. A single Postgres will comfortably handle thousands of jobs per second when the work per job is short and the payload stays small. Push toward tens of thousands per second with many workers all hammering the same table and you will start to feel lock contention and autovacuum pressure. That is the point where a dedicated broker earns its keep. But that point is much further out than most teams assume. I have watched systems ship on this approach and run for three years without ever touching the ceiling.

LISTEN/NOTIFY: from polling to pushing

A table queue that you poll every second works, but it is wasteful. Postgres has a built-in pub/sub mechanism, LISTEN/NOTIFY, that lets your worker wake up the instant a job arrives instead of blindly polling.

public async Task WaitForJobsAsync(ChannelWriter<string> signal, CancellationToken ct)
{
    await using var conn = new NpgsqlConnection(_connectionString);
    await conn.OpenAsync(ct);

    conn.Notification += (_, e) => signal.TryWrite(e.Payload);

    await using (var listen = new NpgsqlCommand("LISTEN new_job;", conn))
        await listen.ExecuteNonQueryAsync(ct);

    while (!ct.IsCancellationRequested)
        await conn.WaitAsync(ct);
}

On the insert side you fire a signal with NOTIFY new_job, or via pg_notify('new_job', job_id::text) inside a trigger. Your worker blocks on WaitAsync until something arrives, drains the queue, and goes back to sleep. No poll interval to tune, no busy loop doing nothing.

One caveat: LISTEN/NOTIFY gives you no guarantees while your worker is offline. Notifications that arrive when nobody is listening are gone. That is why the table stays the source of truth and NOTIFY is only the alarm clock. On startup you always run a plain claim pass before you begin listening, so you never miss whatever landed during your downtime.

JSONB: MongoDB in a column

For semi-structured data, think product attributes that differ per category, people reach for a document database. Postgres has jsonb, a binary JSON type with real indexing. In EF Core you map it directly.

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public JsonDocument Attributes { get; set; } = default!;
}

protected override void OnModelCreating(ModelBuilder b)
{
    b.Entity<Product>()
        .Property(p => p.Attributes)
        .HasColumnType("jsonb");
}

If you want to search on a field inside that JSON, put a GIN index on it:

CREATE INDEX idx_product_attrs ON products USING gin (attributes);

After that, a query like "give me every product where color is red" is an indexed lookup, not a full scan:

SELECT id, name FROM products WHERE attributes @> '{"color": "red"}';

This is the case where Postgres replaces a whole separate database and you give up nothing. You get schema freedom where you want it, and you keep transactions and joins for the rest of your model. Standing up a real document database next to it is, for most .NET apps, pure extra work.

Full-text search and vectors: here is the nuance

Postgres ships full-text search with tsvector and tsquery. For a blog, a docs site or a product catalogue, that is plenty.

SELECT id, title
FROM articles
WHERE to_tsvector('english', title || ' ' || body)
      @@ plainto_tsquery('english', 'skip locked queue');

Add a GIN index on the tsvector and it scales fine into the millions of rows. What you do not get is what Elasticsearch gives you: fuzzy matching out of the box, advanced relevance tuning, faceted aggregations, and an ecosystem built around search. If you have a search box where people occasionally type something, Postgres is enough. If search is the core feature of your product, you will hit that line, and then a real search engine is not a luxury.

The same applies to vectors. The pgvector extension gives you a vector type and similarity search, and with Pgvector.EntityFrameworkCore you map it cleanly into your model:

public class Document
{
    public int Id { get; set; }
    public string Content { get; set; } = "";
    public Vector Embedding { get; set; } = default!;
}

// Nearest neighbours on cosine distance
var similar = await db.Documents
    .OrderBy(d => d.Embedding.CosineDistance(queryEmbedding))
    .Take(10)
    .ToListAsync(ct);

For a RAG feature on top of an existing app this is ideal. Your embeddings sit next to your data, in the same transaction, with the same backup. Only when you head toward hundreds of millions of vectors with strict latency targets does a dedicated vector database get interesting. Almost no line-of-business app lives there.

Where I say no

Cache is the example where the "everything in Postgres" line goes too far for me. Yes, you can make an UNLOGGED table that skips the WAL and use it as a key-value store. But a cache that eats your database connection pool and rides on the same CPU as your transactions is not an upgrade over Redis. It is a way to load your most expensive, hardest-to-scale component with work that was meant to take load off it in the first place. If you genuinely need a hot cache, put Redis next to it. That is the one time the extra box pays for itself.

And then the big one: this is still a single machine. Every workload you put in Postgres shares the same CPU, the same memory, the same I/O. As long as you stay within what one solid server can handle, that is a feature, because everything sits in one place with one backup and one place to debug. The moment you approach that ceiling it becomes a shared bottleneck where your search queries slow down your queue and your vector search gets in the way of your transactions. The skill is not cramming everything into Postgres. It is knowing when to lift a workload out.

How I approach it

Start with everything in Postgres. Seriously. Queue, JSON, search, embeddings, all of it in the database you already run. You get an enormous amount of simplicity back: one connection string, one backup strategy, transactions that hold true across your whole system.

Then measure where it hurts. Not speculatively, actually measure. If your queue throughput hits a wall, lift that queue out and nothing else. If search becomes the core feature, give search its own system. You pull workloads out one at a time, on the strength of numbers, not on the strength of a blog post telling you that you need Kafka.

That is the real point of "Postgres for everything." Not that Postgres literally does everything best, but that it does almost everything well enough to start with, and "well enough to start with" is exactly what most projects need.

Frequently asked questions about Postgres as an all-rounder in .NET

When is a SKIP LOCKED table queue no longer enough? Once you head toward tens of thousands of jobs per second with many concurrent workers on the same table, you start to feel lock contention and autovacuum pressure. For most line-of-business systems that point is much further out than expected, so measure your real throughput before you spin up a dedicated broker.

Can I trust LISTEN/NOTIFY for critical jobs? No, not as the only mechanism. Notifications that arrive while your worker is offline are lost, so the table stays your source of truth and NOTIFY is only the alarm clock. Always run a claim pass on startup before you begin listening.

Does jsonb really replace a document database in EF Core? For most .NET apps, yes. You map jsonb directly, add a GIN index for containment queries, and you keep transactions and joins for the rest of your model. Standing up a separate document database alongside it mostly just adds operational work.

Is pgvector enough for an AI feature? For a RAG or similarity feature on top of an existing app, almost always. Your embeddings live next to your data in the same transaction and backup. Only at hundreds of millions of vectors with strict latency targets does a dedicated vector database become worth it.

Why do you advise against Postgres as a cache? Because a cache workload shares the same CPU, connection pool and I/O as your transactions, loading your most expensive component with work that was meant to relieve it. If you need a real hot cache, put Redis next to it; that is the exception where the extra component pays for itself.

Further reading


Conclusion: Treat "Postgres for everything" as a starting point, not a dogma. Begin with queue, JSON, search and vectors in the database you already run, measure where it genuinely hurts, and only then lift out the one workload the numbers point you to.

Sources: PostgreSQL for Everything · Hacker News discussion.

Want to stay updated?

Subscribe to my newsletter or get in touch for freelance projects.

Get in Touch