For a trivially small set of mapping data, I like using VALUES to create a "virtual" staging table:
UPDATE reward_members
SET member_status = status_map.new_status
FROM (VALUES ('gold', 'gold_group'),
('bronze', 'bronze_group'),
('platinum', 'platinum_group'),
('silver', 'silver_group')
) AS status_map (old_status, new_status)
WHERE member_status = status_map.old_status;
I find this to be more readable than the CASE statement version.As a data analyst this technique is also great for scrubbing dirty data at query time when you don't have the ability to just fix it.
I've had issues in the past with atomic transactions in Django still appearing to have race conditions and I think this explains why. Really appreciate it.
If data correctness is an absolute must, you should go with serializable.
S1> BEGIN;
S1> UPDATE ints SET n = n+1;
S2> BEGIN;
S2> DELETE FROM ints WHERE n = 2;
-- S2 blocks since the DELETE is trying to modify a row
-- currently being updated.
S1> COMMIT;
-- S2 unblocks.
S2> COMMIT;
S3> SELECT * FROM ints;
n
---
2
3
This is working as designed and intended. This is how the READ COMMITTED transaction isolation level works. If you want higher isolation levels like REPEATABLE READ or SERIALIZABLE, then use those. If you need snapshot isolation, then use that. READ COMMITTED is the default isolation level because it's the most usable isolation level for the vast majority of situations, but having an RDBMS that handles concurrency for you doesn't mean that you don't have to understand how that concurrency model works.The tradeoff for these higher isolation levels is that instead of getting results that you might not necessarily expect, you'll instead get deadlocks and forced transaction rollbacks, and your application will need to account for that instead and may need to resubmit queries in certain situations. This is the fundamental tradeoff between performance and isolation.
> CREATE TABLE reward_members (id bigint, member_status text);
> INSERT INTO reward_members (values (0, 'silver'));
> BEGIN;
> SELECT * FROM reward_members WHERE member_status = 'gold' FOR UPDATE;
> SELECT * FROM reward_members WHERE member_status = 'bronze' FOR UPDATE;
> SELECT * FROM reward_members WHERE member_status = 'platinum' FOR UPDATE;
-- In concurrent transaction: > UPDATE reward_members SET member_status = 'platinum' WHERE id = 0 AND member_status = 'silver';
> SELECT * FROM reward_members WHERE member_status = 'silver' FOR UPDATE;
> UPDATE reward_members SET member_status = 'gold_group' WHERE member_status = 'gold';
> UPDATE reward_members SET member_status = 'bronze_group' WHERE member_status = 'bronze';
-- In concurrent transaction: > UPDATE reward_members SET member_status = 'gold' WHERE id = 0 AND member_status = 'platinum';
> UPDATE reward_members SET member_status = 'platinum_group' WHERE member_status = 'platinum';
> UPDATE reward_members SET member_status = 'silver_group' WHERE member_status = 'silver';
> COMMIT;
> SELECT * FROM reward_members;
id | member_status
----+---------------
0 | gold
What you would need instead are "predicate locks". Unfortunately Postgres does not expose predicate locks to the user. Predicate locks lock all rows that match some predicate and prevent any row where the predicate is false from being modified so the predicate is now true. If Postgres did have predicate locks, you could grab a predicate lock on "membership_status = 'gold'", before running the gold UPDATE. That would prevent any row from concurrently having it's membership_status to gold while the transaction is executing.Stricter isolation levels are an option. I would only use them if data correctness is an absolute must. I try to avoid them because they abort any queries that would otherwise have a race condition. You then have to work around queries spontaneously failing.
If you want to avoid this you either have to use the case statement version or use a stricter isolation level. Either repeatable read or serializable would prevent this. I've written about read skew, other related race conditions, and how to prevent them in a series of blog posts you can find here[0][1][2].
On a related note, efficiently batching disjoint updates is an interesting problem. One of my coworkers wrote about how he solved this exact problem in Redshift[3]. He found the most efficient way was to create a staging table with what old values corresponded to what new values. Then you can join the staging table with the table you are updating.
[0] http://malisper.me/postgres-transactions-arent-fully-isolate...
[1] http://malisper.me/postgres-row-level-locks/
[2] http://malisper.me/postgres-transaction-isolation-levels/
[3] https://heapanalytics.com/blog/engineering/redshift-pitfalls...