← back to the skyAnsh Chaturmohta
Tech · 7 min read · May 2026

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.

Read on Medium

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:

  1. SELECT for the row matching the lookup (idempotency_key=idempotency_key).
  2. If found, return it with created=False.
  3. If not found, run INSERT inside a nested atomic() block (a savepoint).
  4. If the INSERT raises IntegrityError (because another transaction inserted the same key in the meantime), Django catches it, rolls back to the savepoint, and does one more SELECT to 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":

  1. Request A calls get_or_create. The SELECT finds nothing. Django attempts the INSERT.
  2. Request B calls get_or_create at nearly the same time. Its SELECT also finds nothing. Django attempts the INSERT.
  3. PostgreSQL detects the conflict on the unique index. Request B's insert blocks, waiting for A to finish.
  4. Request A commits. The row with key "abc123" is now permanent.
  5. PostgreSQL unblocks Request B. The key now exists as a committed row, so B's insert raises IntegrityError.
  6. Django catches the error, rolls back to the savepoint, runs a fresh SELECT, and returns the existing row with created=False.
  7. Request B skips the metadata creation (since created is False) 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 on IntegrityError.
  • Rails: find_or_create_by! does the same try-insert-then-select flow.
  • Raw SQL: INSERT ... ON CONFLICT DO NOTHING followed by a SELECT, or INSERT ... 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.