惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

D
DataBreaches.Net
L
LangChain Blog
博客园_首页
J
Java Code Geeks
博客园 - 【当耐特】
Microsoft Azure Blog
Microsoft Azure Blog
小众软件
小众软件
WordPress大学
WordPress大学
V
Visual Studio Blog
T
The Blog of Author Tim Ferriss
U
Unit 42
酷 壳 – CoolShell
酷 壳 – CoolShell
Recent Announcements
Recent Announcements
C
Check Point Blog
IT之家
IT之家
Engineering at Meta
Engineering at Meta
N
Netflix TechBlog - Medium
A
About on SuperTechFans
aimingoo的专栏
aimingoo的专栏
D
Docker
有赞技术团队
有赞技术团队
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
阮一峰的网络日志
阮一峰的网络日志
I
InfoQ

Giant Robots Smashing Into Other Giant Robots

From In-House PM to Consulting PM: What I Didn't Expect Client success starts before kickoff 5 easy, actionable tips for software development in healthcare Announcing importmap-update: automated dependency updates for importmap-rails When to vibe code an app and when to hire someone Why leaders have to know about PMS 🩸 Is AI ruining my brain? Tech Leaders Meetup is coming to Edinburgh Designers already think in React Tech Leaders Meetups are back in London this autumn GPT and Claude go to heraldry school Don’t hire thoughtbot to write code AI makes creating software faster, but in regulated industries, judgment matters more Tech Leaders meetup in Amsterdam PMs Don't Need to Code, but They Do Need to Understand How healthcare tech teams innovate while balancing speed and security Can’t touch the DOM? Reach for :has() to style any element Buying Time, Choosing Words: Consulting Through Diplomatic Communication thoughtbot around the world, meet us at upcoming events Modeling State Transitions in Postgres Humid 1.0: React server-side rendering in Rails can be easy! A prototype is not a product. It's a conversation. New: The State of Software Delivery in Healthcare Sign in with Google for React Native What founders told us about working with AI tools for startups Join us: Building Secure Healthcare Systems Upcase has retired, but the learning continues The Bike Shed Ep 506: The Muppet Software Team Migrating to native stack navigation, with a surprise from iOS 26 Past and present thoughtbotters at LRUG this Monday
Inserting State Transitions in Postgres
Thiago Araújo Silva · 2026-08-24 · via Giant Robots Smashing Into Other Giant Robots

In Modeling State Transitions in Postgres, we replaced a status column on users with an append-only user_statuses table. That design gives us full history that can be queried efficiently for the most common cases.

But it introduces an edge case worth addressing: what happens when two transactions try to change the same user’s status at the same time? With a column, the overwrite is harmless at the database level. With an append-only model, both records survive, so the problem needs to be addressed explicitly.

The race condition

Say user Alice is in pending status. Two admins change her status at the same time: one approves, the other denies.

If the timing is unlucky, both transactions read the current status before either commits, and both insert their own row:

Step Transaction A Transaction B
1 BEGIN
2 Read current status: pending
3 BEGIN
4 Read current status: pending
5 Insert approved
6 COMMIT
7 Insert denied
8 COMMIT

Both succeed. The user_statuses table now looks like this:

id user_id status created_at
1 1 pending 2026-07-10 09:00:00
2 1 approved 2026-07-15 11:00:00
3 1 denied 2026-07-15 11:00:01

Alice was approved and denied within a second. Both admins saw pending and acted on it independently, without knowledge of each other’s decision.

How a status column on users avoids this

With a status column on users (the more common design), both transactions would do:

-- Transaction A
UPDATE users SET status = 'approved' WHERE id = 1;

-- Transaction B
UPDATE users SET status = 'denied' WHERE id = 1;

Postgres serializes the updates, so the last writer wins. There’s only one column holding one value, so the database never reaches a contradictory state.

That said, a silent overwrite isn’t necessarily harmless in a real application. The second admin undoes the first one’s decision without knowing it happened. And if there are side effects tied to the transition, like sending emails or calling external APIs, both fire even though only one transition should have gone through.

Why append-only doesn’t get serialization for free

With a column, one value overwrites another. With inserts, both rows end up in the table, and the history contains a transition that should never have happened. There’s no overwrite to mask the problem. Either way, neither approach prevents concurrent transitions on its own.

Handling concurrency in an append-only model

We need a way to make the second transaction wait until the first finishes. SELECT ... FOR UPDATE does this by locking a row for the duration of the transaction. The parent users row is a natural choice since it already exists and is unique per user:

BEGIN;

-- Lock the user row until this transaction finishes
SELECT id FROM users WHERE id = 1 FOR UPDATE;

-- Read current status
-- Check if the transition is valid
-- Insert the new status

COMMIT;

With that, here’s what happens with two concurrent transactions:

Step Transaction A Transaction B
1 BEGIN
2 SELECT ... FOR UPDATE (acquires lock)
3 BEGIN
4 SELECT ... FOR UPDATE (blocked)
5 Read current status: pending
6 Insert approved
7 COMMIT (releases lock)
8 (unblocked, acquires lock)
9 Read current status: approved
10

Transaction B now sees approved as the current status, not pending. It can make an informed decision about what to do next.

This works under READ COMMITTED, the default transaction isolation level in Postgres. No configuration changes needed.

Adding a transition check

The lock serializes access, but it doesn’t reject invalid transitions. Transaction B still runs its insert unless we check:

BEGIN;

SELECT id FROM users WHERE id = 1 FOR UPDATE;

-- Read current status
SELECT status
FROM user_statuses
WHERE user_id = 1
ORDER BY created_at DESC, id DESC
LIMIT 1;
-- Returns: 'approved'

-- Is approved -> denied a valid transition?
-- No. Roll back.

ROLLBACK;

The transition rules are application logic. A simple map of allowed transitions is enough:

null     -> pending
pending  -> approved, denied
approved -> (terminal)
denied   -> pending

Transaction B reads approved, checks the map, and rolls back because approved to denied is not allowed. Alice stays approved.

The full sequence with both the lock and the check:

Step Transaction A Transaction B
1 BEGIN
2 Lock user row
3 BEGIN
4 Lock user row (blocked)
5 Read status: pending
6 pending -> approved? Valid. Insert.
7 COMMIT
8 (unblocked)
9 Read status: approved
10 approved -> denied? Invalid.
11 ROLLBACK

What about serializable isolation?

Postgres offers another approach: set the transaction isolation level to SERIALIZABLE. Instead of locking up front, both transactions proceed optimistically. At commit time, Postgres checks whether the result is consistent with some serial execution order. If not, it aborts one transaction with a serialization error.

This would also prevent the race condition above, but it has practical downsides:

False positives. Postgres tracks reads using predicate locks (SIRead locks). These start at tuple granularity but escalate to page or relation level to conserve memory. When that happens, two transactions operating on different users whose rows happen to live on the same heap page will conflict even though their data doesn’t overlap.

Retry logic. The aborted transaction gets an error, not a blocked wait. The application must catch it and retry, which adds complexity. With SELECT FOR UPDATE, the second transaction simply waits and then proceeds with fresh data.

Overhead. Tracking predicate locks across all serializable transactions has a memory and CPU cost. Postgres provides tuning parameters to control this, but it’s additional operational complexity.

SELECT FOR UPDATE is the simpler and more predictable choice for this problem. The cost is minimal: it’s a primary key lookup and a row-level lock held only for the duration of the transaction.

Wrap-up

Any real application that validates transitions or triggers side effects like emails and API calls needs a lock to serialize concurrent state transitions, regardless of whether you use a column or an append-only table. The column approach masks the problem by silently overwriting, but the side effects still fire twice.

With SELECT FOR UPDATE on the parent row, the second transaction blocks until the first commits, then reads the updated state. A transition check inside the locked section rejects invalid transitions. Since you need the lock either way, the append-only model doesn’t add complexity. It just makes the concurrency requirement explicit, and you get full history in return.

The transition check shown here uses a hardcoded map. In practice, this validation lives in your application code.