An import job timed out at 100k rows because of one `in` check on a list
A customer import that took seconds in testing ran for over an hour on a real file and hit the worker timeout. The cause was a membership test on a list inside a loop. How we found it with a profiler, measured the fix with timeit, and guarded against it coming back.
The import feature lets a business upload a CSV of customers. For months it handled files of a few thousand rows without anyone noticing it. Then a new customer uploaded their full CRM export, 112,000 rows, and the job died:
[2026-10-14 09:41:07] worker.ERROR: Job imports.customers (id=55102) exceeded timeout of 3600s
[2026-10-14 09:41:07] worker.WARNING: killed pid=8121 rss=410MB rows_processed=61840
An hour, and barely past halfway. Memory was fine, CPU on that worker was pinned at 100% for the entire run, and the database showed almost no load. Whatever was slow, it was pure Python.
A first measurement
We generated synthetic files of different sizes and timed the job locally:
| Rows | Time |
|---|---|
| 5,000 | 2.1 s |
| 10,000 | 8.0 s |
| 20,000 | 31.5 s |
| 40,000 | 126 s |
Each time the input doubled, the run time roughly quadrupled. That pattern is the signature of quadratic work: O(n²). Something was doing work proportional to the number of rows, once per row.
Finding the line
cProfile pointed straight at it:
python -m cProfile -s cumulative -m imports.run --file customers_20k.csv | head -20
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.020 0.020 31.402 31.402 run.py:1(<module>)
1 30.912 30.912 31.310 31.310 customers.py:42(import_rows)
20000 0.211 0.000 0.211 0.000 customers.py:88(normalise_email)
...
Nearly all the time was tottime inside import_rows itself, not in anything it called. That means the
cost was in plain operations on that function's own lines. The function:
def import_rows(rows):
seen_emails = []
existing = [c.email for c in Customer.objects.only("email")]
created, skipped = [], []
for row in rows:
email = normalise_email(row["email"])
if email in existing or email in seen_emails:
skipped.append(row)
continue
seen_emails.append(email)
created.append(Customer(email=email, name=row["name"]))
Customer.objects.bulk_create(created, batch_size=1000)
return len(created), len(skipped)
Two lists, existing and seen_emails, and an in check against each on every row.
Why in on a list is slow
x in some_list checks elements one by one until it finds a match or reaches the end. For a list of
length m, that is up to m comparisons. It is O(m), and for values that are not in the list (most rows in
an import) it always scans everything.
In this loop:
existingheld every customer already in the account. For the new customer it was empty, but for an established account it could be tens of thousands.seen_emailsgrew by one per row, so on row k it held about k entries.
Summing the second check over n rows gives roughly 1 + 2 + ... + n, which is about n²/2 comparisons. For 112,000 rows that is over six billion string comparisons, each a Python-level operation. No wonder it needed hours.
Why sets and dicts are fast
A set or dict stores items in a hash table. Checking membership computes the hash of the value and
looks in one bucket. On average that is O(1), independent of the collection's size. The Python
documentation's time complexity table lists x in s as O(n) for lists and O(1) average for sets and dicts.
The fix is literally changing two brackets. That is why this bug is so common: the slow and fast versions look almost the same, and with small test data they run at the same speed.
Environment
| Item | Value |
|---|---|
| Language | Python 3.12 |
| Framework | Django 5.1, task run by a queue worker |
| Input | CSV, 112,000 rows, about 9% duplicate emails |
| Worker timeout | 3,600 s |
1. Use sets for membership
def import_rows(rows):
existing = set(Customer.objects.values_list("email", flat=True))
seen = set()
created, skipped = [], []
for row in rows:
email = normalise_email(row["email"])
if email in existing or email in seen:
skipped.append(row)
continue
seen.add(email)
created.append(Customer(email=email, name=row["name"]))
Customer.objects.bulk_create(created, batch_size=1000)
return len(created), len(skipped)
Building existing is one O(m) pass. Each loop iteration is now O(1) on average. The whole function is
O(n + m) instead of O(n × m + n²).
We also switched from only("email") to values_list(..., flat=True), which avoids building a model
instance for every existing customer. That change is small next to the set, but it cut memory too.
2. When you need the item, not just "is it there", use a dict
Later the same job learned to update existing customers instead of skipping them. That needs the record, not a yes/no. A dict keyed by email keeps lookups O(1):
existing = {c.email: c for c in Customer.objects.filter(account=account)}
for row in rows:
email = normalise_email(row["email"])
if (customer := existing.get(email)) is not None:
customer.name = row["name"]
to_update.append(customer)
else:
...
3. Let the database do it when the data is too big for memory
For accounts with millions of customers, loading every email into a set is not ideal either. For those we
use a unique index on (account_id, email) and bulk_create(..., ignore_conflicts=True), so the database
does the duplicate check with an index lookup. The in-memory set remains for duplicates inside the file
itself.
1. Micro-benchmark with timeit. We measured the membership check alone to confirm the theory before looking at the full job:
import timeit
setup = "data = [f'user{i}@example.com' for i in range(100_000)]; s = set(data); x = 'missing@example.com'"
print(timeit.timeit("x in data", setup=setup, number=1_000))
print(timeit.timeit("x in s", setup=setup, number=1_000))
1.21
0.000031
A thousand lookups for a missing value: about 1.2 seconds on the list, about 31 microseconds on the set, on one laptop. The exact numbers depend on the machine; the ratio is the point.
2. The same scaling table, after the fix:
| Rows | Before | After |
|---|---|---|
| 10,000 | 8.0 s | 0.21 s |
| 40,000 | 126 s | 0.78 s |
| 112,000 | timed out | 2.3 s |
Doubling the rows now roughly doubles the time. Linear, as expected.
3. A test that guards the complexity, not a wall-clock number. Timing assertions in CI are flaky. We instead assert that run time grows roughly linearly, using a large ratio of headroom:
def test_import_scales_linearly(make_rows):
small = timed(lambda: import_rows(make_rows(5_000)))
large = timed(lambda: import_rows(make_rows(40_000)))
# 8x input. Linear would be ~8x time; quadratic would be ~64x.
assert large / small < 20
On the old code the ratio was about 60. On the new code it is about 8. The threshold of 20 is loose enough to survive a noisy CI runner and tight enough to catch a return to quadratic behaviour.
4. Lint for the pattern. We added a review checklist item: "membership checks inside loops use a set
or dict". Linters such as Ruff flag some related patterns, like PERF401 for manual list building, but
none reliably catch in list inside a loop, so it stays a review rule.
5. Security implications. A quadratic path on user-supplied input is also a denial-of-service risk: anyone allowed to upload a file could tie up a worker for hours. Beyond the fix, we added a row limit per upload with a clear error message, and the worker timeout now marks the import as failed and notifies the user instead of retrying it three more times.
Takeaways
x in listscans the whole list. Inside a loop, it makes the loop O(n²).- Use a
setfor "have I seen this", adictwhen you need the matching item. - Time doubling-sized inputs. Four times slower per doubling means quadratic.
- Test scaling ratios, not absolute times, so the check is stable in CI.
References: Python sets, timeit module, The Python profilers, Django bulk_create.
Add a label
Embed this post
<iframe src="https://hibug.me/list-membership-in-loop-quadratic-timeout" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I have worked as a backend engineer for about twelve years, mostly in Python and Go, with enough JavaScript on the side to keep the frontend team honest. Most of that time was spent on internal platforms: billing jobs, import pipelines and the services everything else quietly depends on. What I keep seeing is that the expensive bugs rarely come from exotic corners. They come from fundamentals we all learned once and then stopped thinking about: a copy that was not deep, a loop that turned quadratic at real volume, an async call nobody awaited, a subclass that broke a rule its parent relied on. Each post here starts from one of those failures, shows how we traced it, the smallest fix that held and the test that keeps it from coming back. Where a principle or pattern helps, I show it on real code, and I am just as clear about where it only adds weight.
Related Stories
A subclass override skipped the validation its base class depended on
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.