May 2026 · WRITING
How Django's get_or_create Quietly Solves Race Conditions You Thought Were Your Problem
When concurrent requests race to insert the same record, most teams reach for retry logic. Django's get_or_create handles it in one line.
IntegrityError · savepoint · unique index · upsert · PostgreSQL
The Setup
You’re building a file upload service with idempotency keys. Clients send a unique key with each upload so retries don’t create duplicates. Your FileRecord model has a unique constraint on idempotency_key:
class FileRecord(models.Model):
idempotency_key = models.CharField(max_length=255, unique=True)
filename = models.CharField(max_length=255)
size = models.BigIntegerField()
created_at = models.DateTimeField(auto_now_add=True)
That unique=True is doing more work than most developers realize.
The Problem: Two Requests, One Key
Two concurrent requests hit your API carrying the same idempotency key. Both try to create a file record (along with related metadata) inside a transaction.
The naive approach falls apart fast. The first transaction inserts the row. The second tries to insert the same key and raises an IntegrityError. In Django’s ORM, once that error fires inside a transaction, the entire transaction is marked as aborted. Every subsequent statement fails with the unfriendly current transaction is aborted, commands ignored until end of transaction block.
So you roll the whole thing back. All the metadata writes, the counter updates, the audit log entries, gone. And you retry from scratch.
This is the situation that usually triggers a sprint of “let’s add retry logic.”
The Reflex: Application-Level Retry Logic
Most teams end up writing something like this:
def upload_file_with_retries(idempotency_key, file_data, user, max_retries=3):
for attempt in range(max_retries):
try:
with transaction.atomic():
return create_file_and_metadata(idempotency_key, file_data, user)
except IntegrityError as e:
if 'idempotency_key' in str(e):
try:
return FileRecord.objects.get(idempotency_key=idempotency_key)
except FileRecord.DoesNotExist:
# Still racing! Retry with exponential backoff
delay = (2 ** attempt) + random.uniform(0, 1)
time.sleep(delay)
continue
else:
raise
It works, but it carries real costs: the control flow is complicated, every retry throws away work done inside the transaction, exponential backoff adds latency, and there are edge cases where both transactions fail to find the record on a get because their transaction snapshots were taken before the other committed.
The Clean Solution: get_or_create
Django’s get_or_create collapses all of that into one call:
def upload_file(idempotency_key, file_data, user):
with transaction.atomic():
file_record, created = FileRecord.objects.get_or_create(
idempotency_key=idempotency_key,
defaults={'filename': file_data.name, 'size': file_data.size}
)
if created:
# Only create metadata and update stats for genuinely new files
create_metadata_and_update_stats(file_record, file_data, user)
return file_record
No retry loop. No exponential backoff. No edge cases around transaction snapshots. One call, and the concurrency problem is handled.
What get_or_create Is Actually Doing
The simplicity of the API hides real moving parts. Reading Django’s source for get_or_create, it does roughly this:
SELECTfor the row matching the lookup (idempotency_key=idempotency_key).- If found, return it with
created=False. - If not found, run
INSERTinside a nestedatomic()block (a savepoint). - If the
INSERTraisesIntegrityError(because another transaction inserted the same key in the meantime), Django catches it, rolls back to the savepoint, and does one moreSELECTto fetch the now-committed row.
Step 4 is the key insight. Django is not doing anything magical with concurrency. It is doing what the retry loop above does, but in a tight, well-tested, single-call package: try the insert, catch the conflict, fall back to a read.
The savepoint in step 3 is critical. Without it, the IntegrityError would poison the outer transaction (the “commands ignored until end of transaction block” problem). By wrapping the insert in its own savepoint, Django isolates the failure and keeps the outer transaction clean.
Why This Works Under Concurrent Load
The reason get_or_create is safe under concurrent calls comes down to how PostgreSQL handles unique constraint conflicts.
When two transactions try to insert the same unique key concurrently, PostgreSQL doesn’t just let both race and hope for the best. If it detects that a conflicting key belongs to an in-flight (uncommitted) transaction, it makes the second transaction wait until the first one resolves. Only then does it re-evaluate:
- If the first transaction committed: PostgreSQL raises a unique violation on the second insert. Django catches this and falls back to
SELECT. - If the first transaction rolled back: the second insert proceeds normally.
This means there’s no window where both transactions think the key doesn’t exist. PostgreSQL’s locking on the unique index provides the coordination that application-level retry loops try (and often fail) to replicate.
Tracing Through the Concurrent Case
Two concurrent uploads with the same idempotency key "abc123":
- Request A calls
get_or_create. TheSELECTfinds nothing. Django attempts theINSERT. - Request B calls
get_or_createat nearly the same time. ItsSELECTalso finds nothing. Django attempts theINSERT. - PostgreSQL detects the conflict on the unique index. Request B’s insert blocks, waiting for A to finish.
- Request A commits. The row with key
"abc123"is now permanent. - PostgreSQL unblocks Request B. The key now exists as a committed row, so B’s insert raises
IntegrityError. - Django catches the error, rolls back to the savepoint, runs a fresh
SELECT, and returns the existing row withcreated=False. - Request B skips the metadata creation (since
createdisFalse) and returns the same record.
Two requests, correct behavior, zero retry logic in your code.
The Same Pattern in Other Frameworks
get_or_create is Django’s name for a pattern that shows up everywhere:
- SQLAlchemy: hand-rolled with a try/except around
session.add()+session.flush(), falling back to a query onIntegrityError. - Rails:
find_or_create_by!does the same try-insert-then-select flow. - Raw SQL:
INSERT ... ON CONFLICT DO NOTHINGfollowed by aSELECT, orINSERT ... ON CONFLICT DO UPDATE(upsert) if you want to update the existing row.
The application-side code is small in every case. The database’s unique constraint enforcement is doing the heavy lifting.
Practical Notes
Always have the unique constraint. get_or_create without a unique constraint on the lookup fields is a race condition waiting to happen. The database constraint is what makes the pattern safe.
Use it at the start of the transaction. In the example above, get_or_create is the first operation. This matters: if Request B blocks on the insert, it hasn’t done any other work yet. No wasted writes, no thrown-away metadata.
Watch the created flag. Tracking the ratio of created=True to created=False over time can surface interesting signals: a misbehaving client sending duplicate keys, a cache invalidation issue, or a change in upstream retry behavior.
Monitor lock waits. Leaning on the database for coordination means you need to watch the database. Long lock waits on unique indexes can signal contention, deadlocks, or runaway transactions holding locks they shouldn’t. PostgreSQL exposes this in pg_stat_activity and pg_locks.
The Takeaway
The retry loop most teams write for concurrent inserts is solving a problem that Django (and the database underneath it) already solved. get_or_create wraps the entire try-insert-catch-conflict-then-select pattern into a single, tested, atomic call. The unique constraint on the database side provides the coordination. Your code just needs to ask the right question: “give me this record, or make it if it doesn’t exist.”
Next time you find yourself drafting an exponential-backoff retry loop around a database insert, check if a get_or_create (or your framework’s equivalent) already does what you need. It almost certainly does.
FIRST PUBLISHED ON MEDIUM · READ IT THERE →