I’ve done a few startups now, and I’ve learned that I know just enough about software development to be a good rubber ducky for my technical cofounders.
At least once a week, James will describe a problem to me from Los Angeles. I listen from Montgomery, Alabama, and repeat it back wrong. James corrects me, then solves it. This is how we’ve built PgCache.
And so, a few weeks back, James and I were working on a stumper: how to handle read-after-write inside transactions. There are existing patterns for dealing with this, with the usual tradeoffs between accuracy, speed, and consistency. And because we’re building towards the commitment that “PgCache never serves stale data”, our existing approach was safe, but a little disappointing: When PgCache sees a transaction, it says “nope” and forwards it to origin.
As we circled the problem, James paused mid-sentence and cocked his head.
“Actually, we’re already tracking when an existing WHERE clause covers a new inbound query. I built that for the covered queries feature. And we’re also reading the CDC stream. So we could just look at incoming reads and pending writes, and use algebra to see whether they intersect or not. If they don’t, then we can safely serve the query from the cache … and then we just wait for the write to come through the Logical Replication stream, and drop it from the list once it does.”
My cue at this point is to say “would that really work?”, so I do. That starts the validation loop.
“Yeah. I think it gets us as close to a guarantee that you’ll never see a read-after-write problem as you can practically get. Even outside of transactions.”
A moment of silence, and we both get silly grins. This has happened before. We run into a tough caching limitation, talk it through, and realize that there’s a great solution hiding in plain sight - right out of Postgres.
Gambling on Postgres to build a startup antipattern
When James started building PgCache, there was never any question that it would be a PostgreSQL thing. He’s been a Postgres diehard since the dot com era. I like Postgres too, but I trust James more.
Since I wear the CEO hat, “Postgres only” can seem like a constraint: we’re self-selecting for a smaller market compared to database-agnostic competitors. But we wanted to build something special, and we wanted to build for the Postgres community.
We saw gaps in the existing consensus: A key-value store like classic Redis obviously works, and at certain scales it works really well. But it pushes most of the hard work like invalidation and consistency onto the application team.
James’ vision, which I’ve embraced, was to build something native to the Postgres ecosystem, so that we could solve some of the classic cache problems at the source. We chose to speak the Postgres wire protocol natively, so that the application sees PgCache as just another Postgres endpoint. We chose to read the logical replication stream (the CDC stream), so we could know in real time what changed. We chose to understand the semantics of queries, not just their bytes.
In the age of 2 month-old AI unicorns, bootstrapping PgCache has often felt a little like an antipattern: It’s been hard, slow, and uncertain. Software as infrastructure is more infrastructure than software, meaning the bar for adoption is incredibly high. But we had a hunch that the Postgres-specific design would keep paying us back in ways we couldn’t predict.
One year in, I’m happy to say that it’s not a hunch anymore: PgCache is a delightful piece of software that has evolved from “a better cache” to “a smart read replica.” It’s so elegant, and so simple, that I’m confident that PgCache will become the default way to scale reads on Postgres by 2030.
The rest of this post is about how being Postgres-first has repeatedly led us to elegant, and often unexpected solutions to thorny problems.
Dividend one: Invalidation, solved upstream
I’ll start with the most obvious problem, invalidation, or “how much should the cache lie to our users?” This is why we built PgCache.
The old saying exists for a reason: There are “only two hard problems in computer science: cache invalidation and naming things.” The moment you cache anything, you have to answer the question of when to stop trusting what you just cached. The easiest solution (in a generic key-value store) is to ignore the question, and have your users choose a Time To Live (TTL) that essentially says “we accept inconsistency for x seconds/minutes.” You hard code that into your app.
PgCache doesn’t have the inconsistency problem.
We listen to the Postgres logical replication stream (the same mechanism Postgres uses to maintain replicas), and when a row changes in the database, we hear about it directly from the source. There’s no application code to write, no key to remember to delete, no TTL to guess at, no background process that bypasses us. If the data changed in Postgres, we know.
Invalidation is what James initially built PgCache to solve. And that decision to build on PostgreSQL has led to innovation after innovation over the past 12 months. Let’s look at a few of those.
Dividend two: Read-after-write, accidentally
This is the problem I opened with. If you have data in the cache, but it’s updated by a write that hasn’t hit the cache yet, you’re serving stale data. This problem is usually solved with one of the common cache architectures, each with their own pros and cons.
(option A) Writing around to origin gives you a fast cache that has consistency/invalidation problems.
(option B) Writing through to both cache and disk simultaneously gives you a safely consistent, but slower cache.
(option C) Another interesting approach is to write back to origin from the cache. This one is fast and consistent, but shifts a whole lot of risk over to the cache, which is now responsible for your writes. The cache becomes too important to fail, and now you have a second crown jewel to protect in your stack.
James’ realization about read-after-write was that PgCache already had the machinery to solve it. By combining existing logic that we’d built, we could leverage the logical replication stream in a novel way, to fill the gap.
The logic came from a PgCache feature called covered queries (or “subsumption”), which is the ability to take two SQL WHERE clauses and algebraically determine whether one result set is a subset of the other. We built it so we could answer narrower queries from broader cached results. (This is very James. If you’ve cached SELECT * FROM orders WHERE customer_id = 42, and a query comes in for SELECT * FROM orders WHERE customer_id = 42 AND status = 'shipped', the second is provably a subset of the first, and we answer it without going back to Postgres.)
The eureka moment was that this algebra also works in reverse, because James built PgCache as a daemon that sits in front of origin and sees read queries, as well as writes. It followed that if we kept a list of outstanding writes (writes passed through the cache but not yet in the CDC stream), we could compare the WHERE clause of any incoming read against each pending write. If the intersection was provably empty, the write could not have affected the read, and we could serve from cache safely. If the intersection was non-empty or undetermined, we would let the read pass through to origin. Once CDC confirmed the write was propagated, we could drop it from the list and reads from the cache would see that write again.
This means we can cache inside transactions, not just around them. And we can address read-after-write even outside transaction boundaries. That’s something traditional read replicas have never done, because they don’t have access to query semantics.
It’s not fully solved yet - the read-after-write feature is under development, and reality has a way of complicating things. But the architectural setup is there, and it works because James made a design choice a year ago, with downstream benefits that we couldn’t have predicted.
Let’s turn to another example.
Dividend three: the hard parts of Postgres, for free
This dividend is less of a “wow” and more of an “oh yeah, that makes sense” - but it’s what quietly decides whether PgCache works on your actual workload or not.
I won’t bury the lede: PgCache stores its cached results in a real, embedded Postgres instance. When a cached query runs, Postgres runs it. We never had to re-implement SQL operators, type handling, or the planner, because we have an actual Postgres doing that work under the hood. Every feature Postgres supports, we inherit for free.
Almost nobody writes raw SQL by hand anymore. Your ORM, your driver, your connection pooler, they all speak the extended query protocol. They send a prepared statement with placeholders (SELECT * FROM orders WHERE customer_id = $1) and bind the values separately. Parameterized queries aren’t an edge case; they’re the default shape of real traffic.
A cache has to do something sensible with that. Our approach is simple thanks to our Postgres foundation: When it receives a prepared statement, PgCache binds the parameters in, and caches on the resulting concrete query. Bind $1 = 42 and you get one cache entry; bind $1 = 99 and you get another. That’s it. Parameterized queries, JOINs, aggregations, GROUP BY, window functions, arrays, enums - they all just work.
Compare that to the path most caches in our space have to take: Build a from-scratch dataflow engine, then re-implement SQL execution so the cache can keep materialized results incrementally fresh. That’s genuinely impressive engineering, but it means every operator, every function, every data type has to be taught to the engine before it’s supported. And the seams show: user parameters end up restricted to certain positions (typically the WHERE clause of the outermost SELECT), and teams running real workloads have hit cases where the query fingerprinting falls over on parameterized queries. One team we talked to walked away from their caching layer for exactly that reason, and built their own sidecar instead.
PgCache, in contrast, can support those caching stumbling blocks natively.
Partitioned tables tell the same story: Large Postgres deployments lean on partitioning constantly, and partitioning is one of those features a from-scratch engine has to explicitly learn. Because PgCache leans on a real Postgres, partitioned tables just work - and we didn’t even plan it! We demo’d it live against a range-partitioned table, because someone asked us to, and PgCache answered.
(Caveat: there are limits that we’re working on: attaching or detaching a partition that already holds rows is a case we still handle conservatively.)
The point here is that we didn’t set out to build “great compatibility.” We set out to cache with Postgres instead of around it, and broad compatibility fell out of that choice. The products that choose to live outside Postgres have to earn every feature one at a time. We get them the day Postgres ships them.
Dividend four: schema changes, automatically
This one is quieter, but anyone who’s run logical replication at scale will appreciate it. James has written more deeply on this elsewhere, so I’ll hit the key point.
Postgres’s logical replication is a beautiful piece of engineering, but it has a famous foot-gun: schema changes. If you ALTER a table in a way the downstream isn’t prepared for, replication breaks. The standard advice for anyone running logical replicas in production is essentially “be very careful, and have a runbook.”
PgCache mostly sidesteps this. Because we are a cache and not a replica - i.e., we can fall back to origin - most schema changes flow through us without any special handling. We see the relation metadata updates in the CDC stream, we invalidate or refresh cached entries that depend on the affected tables, and we keep serving traffic. (Note: we are aware of a few narrow exceptions here, which we plan to address in future releases.)
As with partitioned tables, we didn’t design for schema-change tolerance. We designed for the broader goal of caching at query semantics rather than at file blocks, and schema-change tolerance is something that fell out of it when James dug into the Postgres documentation. It was yet another dividend of building Postgres-first.
Dividend five: what’s coming, which we couldn’t build any other way
The interesting part of Postgres right now isn’t Postgres itself. It’s the ecosystem: pgvector for vector search, ParadeDB for full-text and analytics, TimescaleDB, and the whole shift toward “use Postgres for everything” that’s happened in the last few years.
A generic cache can’t do anything with these. If you put a key-value cache in front of a pgvector query, it doesn’t understand vectors, doesn’t understand similarity search, can’t reason about which queries are subsets of which others. It just stores bytes. Invisible to the parts of your stack that actually matter.
PgCache, because it lives in the Postgres world, can reason about extensions natively. Now, we’re not there yet. Supporting pgvector well means solving hard problems about dense vectors. But it’s possible. ParadeDB is possible. Anything that speaks Postgres, eventually, is possible. And it’s possible for the same reason parameterized queries were: we built ourselves on top of Postgres, so we get to grow with it, instead of chasing it.
This is the most forward-looking dividend, and the most speculative. But it’s also the one that makes me most certain we chose right. The Postgres world will keep getting more interesting. And we will keep being able to follow it, because we built ourselves to live inside it.
What we got, and what we paid for it
When we first started talking about PgCache publicly, we positioned it as a Redis alternative: Easy, drop-in, lower-effort. That got early conversations but put us in a losing position. People who heard “Redis alternative” wanted to know how fast we were at lookups in microseconds. That was never the game. We cache the right things, automatically, without the application team owning invalidation. Speed matters, but it’s table stakes.
(Note: we’ve become more competitive with Redis on speed over the past few months: First by using Postgres materialized views when that makes sense; and second thanks to some in-memory tricks that James released in v0.5.0.)
This spring we re-introduced PgCache as a smart read replica, which is what we actually do. You change one connection string. You stop paying to replicate the 80% of your data that nobody reads. You don’t run a Redis layer, don’t write invalidation logic, don’t have stale-data bugs. It looks like a read replica that’s smaller, cheaper, and faster.
We didn’t plan this. But James had an intuition that the right way to cache Postgres was to do it with Postgres (“just use Postgres for everything,” right?). Since then, we’ve watched it compound in ways we didn’t predict.
I’d love to tell you what the next dividend is going to be … but I don’t know. It’ll probably happen when we run into the next tangly problem, and it’ll be unexpected and “deceptively simple,” as a friend recently described James’ approach.
If you are earlier in the process and still working out which caching layer your workload needs, start with our guide to PostgreSQL caching.
As I wrap this up, because I’m the CEO, I have to tell you: if any of this sounds like something you’d want in front of your Postgres workload, there’s a free trial on AWS Marketplace. Shoot me an email and we’ll help you find out if PgCache fits your setup.
Thanks for reading and we’re glad you’re here! If you’re keen to follow along, and find out what happens next, then join our Discord and mailing list!
We’re just getting started.
-Philip