ORM / migrations / gunicorn / deploys
Hire Senior Django
Developers in Morocco
Far more people list Django on a CV than have ever run it under load. That gap is why your last three interview rounds went nowhere. This page is the filter.
Query plans, index discipline, migrations that do not lock a table, and a deploy anyone on your team can run. Morocco on UTC+0, so your engineer is online when your team is.
Bring a slow page. We read the plan with you on the call.
01 / The gap
Django you can learn in a weekend,
and Django that survives Monday
Every line on the left is something we have found in a real production codebase. None of it is stupid. It is what the tutorial teaches, and the tutorial is not wrong, it is just not finished.
The distance between the two columns is the thing you are actually hiring. Print this and take it into your next interview, ours included.
Model.objects.all() in the template. Fast on a laptop with 40 rows.
select_related, prefetch_related, .only(), and an assertNumQueries in CI so the count cannot drift back.
db_index=True on whatever felt slow that week.
Read the plan first. Usually a partial or composite index, built CONCURRENTLY.
makemigrations, push, hope. Find the lock during business hours.
Read the SQL. Expand, backfill, contract. Schema and code ship as separate compatible deploys.
ATOMIC_REQUESTS=True everywhere, because it sounds safe.
Tight transaction.atomic() blocks. Never held open across an external API call.
A cron management command, or a threading.Thread fired from a view.
Celery with idempotent tasks, retry backoff with jitter, and a dead letter queue somebody reads.
Defaults. Postgres runs out of connections before the app runs out of capacity.
Pooling matched to the Django version and the deployment, not copied from a 2019 blog post.
runserver, or gunicorn on defaults facing the internet directly.
Workers sized from measured concurrency and the memory ceiling, behind a buffering proxy.
Read the value, add one, save. Two requests, one lost write, noticed a month later.
F() expressions, and select_for_update() where the money is.
A for loop calling .save() fifty thousand times.
bulk_create and bulk_update with a batch size, .iterator() so the queryset never lands in memory.
Business rules live in it, so they only run when a human clicks a button.
Admin is data entry. Rules live in services that the admin, the API and the job all call.
except: pass around the flaky part. The bug stops reporting, not happening.
Catch what you expect, let the rest surface, and make sure the surface reaches someone on call.
Pinned to whatever version the project started on, because upgrading is frightening.
On a supported release, with the test suite good enough that moving between them is routine.
The one question that sorts them
“Add a NOT NULL column to a production table with fifty million rows, without taking the site down. Talk me through it.”
Somebody who has done it answers in under two minutes and says three things: the lock, expand and contract, and shipping the schema change separately from the code change. Somebody who has not will start describing a maintenance window. Ask us this on the call.
02 / Where you actually stand
Your Django version may already be unsupported
This is the first thing we check and it is the one people get wrong most often, because “it still works” and “it still gets security patches” feel like the same sentence and are not.
Django 4.2 LTS reached end of extended support on 7 April 2026. If you are on it, you have been running unpatched since the spring.
| Release | Status | What that means for you |
|---|---|---|
| 4.1 and earlier | Unsupported | No security patches at all |
| 4.2 LTS | Died 7 Apr 2026 | No security patches since April |
| 5.0 | Died 2 Apr 2025 | No security patches |
| 5.1 | Died 3 Dec 2025 | No security patches |
| 5.2 LTS | Supported to Apr 2028 | The safe place to be today |
| 6.0 | Extended, to Apr 2027 | Security fixes only since Aug 2026 |
| 6.1 | Current release | Latest is 6.1.1 |
5.2 LTS is where you want to be
Supported until April 2028, which buys you roughly eighteen months of not thinking about this. For most teams on an unsupported release, the correct target is 5.2 rather than the newest thing.
The versioning changes in 2028
From the Django 2028 release the numbering becomes the year, and every feature release gets the same three-year support window rather than only LTS versions. Upgrades get less dramatic. Worth knowing before you plan a two-year roadmap.
Tests come before the upgrade
If your suite is thin, the first weeks are tests, not version bumps. Upgrading Django with no safety net is how teams end up reverting on a Friday night. We will say this out loud rather than take the work and find out together.
One version at a time
Deprecation warnings raised to errors, both versions green before moving on. A jump from 4.2 straight to 6.1 skips every warning the framework was trying to give you.
Dates from djangoproject.com, checked 18 September 2026.
03 / Index discipline
Never add an index until you have read the plan
Adding an index because a page feels slow is guessing with a bill attached. Every index is maintained on every insert, update and delete, takes disk, and has to stay in memory to be worth anything. A good share of the indexes we find in a mature Django project have never been used once.
So the order never changes. Measure, read the plan, then work out what kind of index this query actually wants.
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders
WHERE status = 'pending'
AND created_at > now() - interval '7 days';
-- Seq Scan on orders
-- (actual time=0.019..94.312 rows=389 loops=1)
-- Filter: (status = 'pending' AND created_at > ...)
-- Rows Removed by Filter: 612844
-- Buffers: shared hit=1204 read=7130
-- 612,844 rows read to return 389, and 7,130 blocks
-- came off disk. Now an index is justified.-- 'pending' is a fraction of a per cent of this table.
-- A full index on status pays to store millions of rows
-- we never query. A partial index does not.
CREATE INDEX CONCURRENTLY orders_pending_recent_idx
ON orders (created_at DESC)
WHERE status = 'pending';
-- CONCURRENTLY because a plain CREATE INDEX holds a lock
-- that blocks writes for the whole build. On a large table
-- in business hours, that is an outage you chose.The same thing, as a Django migration
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations, models
class Migration(migrations.Migration):
# CONCURRENTLY cannot run inside a transaction, and Django
# wraps migrations in one by default. Miss this line and it
# fails on the first table big enough to matter.
atomic = False
operations = [
AddIndexConcurrently(
model_name="order",
index=models.Index(
fields=["-created_at"],
condition=models.Q(status="pending"),
name="orders_pending_recent_idx",
),
),
]A sequential scan is often correct
On a small table the planner is right and an index would be slower. An engineer who cannot tell you when a seq scan is the better plan is adding indexes by reflex.
Compare the estimate to the actual
When the planner expects 12 rows and gets 40,000, the problem is stale statistics, not a missing index. Run ANALYZE before you add anything.
Column order decides what an index serves
An index on (a, b) answers queries on a, and on a and b together. It does nothing for b alone. Equality columns first, the range column last.
Find the ones nobody uses
pg_stat_user_indexes with idx_scan at zero is a list of indexes costing you write throughput for nothing. Dropping those is often the fastest available win.
04 / Migrations
The interview question, answered properly
Fifty million rows, and you need the column to be NOT NULL. The naive migration takes a lock that holds every read and write on that table while Postgres validates every row. Here is the version that nobody notices.
-- 1. EXPAND. Add it nullable. Metadata only, instant, and the
-- running code neither knows nor cares that it exists.
ALTER TABLE customer ADD COLUMN phone_number varchar(32);
-- 2. Deploy code that writes to both columns, reads the old one.
-- 3. BACKFILL in batches, throttled, off peak. One big UPDATE
-- would hold a lock and bloat the table in a single pass.
UPDATE customer SET phone_number = phone
WHERE id BETWEEN %s AND %s AND phone_number IS NULL;
-- 4. Validate WITHOUT the exclusive lock. This is the trick:
-- NOT VALID skips the scan, VALIDATE takes only a
-- SHARE UPDATE EXCLUSIVE lock, so writes keep flowing.
ALTER TABLE customer
ADD CONSTRAINT phone_number_not_null
CHECK (phone_number IS NOT NULL) NOT VALID;
ALTER TABLE customer VALIDATE CONSTRAINT phone_number_not_null;
-- 5. Now SET NOT NULL is cheap. Postgres trusts the validated
-- CHECK and skips the full scan entirely.
ALTER TABLE customer ALTER COLUMN phone_number SET NOT NULL;
-- 6. CONTRACT. Deploy code that reads the new column, then
-- drop the old one once nothing has touched it for a while.Schema and code are separate deploys
Every intermediate state has to work with the code that is currently running and the code about to be running. Get that right and you never need a maintenance window. Get it wrong and the gap between migrate and deploy is your outage.
Read the SQL before it runs
sqlmigrate prints exactly what will execute. Thirty seconds of reading tells you whether you are about to rewrite the whole table. Adding a column, changing a type and adding a constraint look identical in a migration file and behave completely differently at ten million rows.
Backfills are throttled, always
A single UPDATE across fifty million rows holds a lock, generates enormous WAL, and bloats the table until autovacuum catches up. Batch it, sleep between batches, and run it when nobody is looking.
Set a lock timeout
lock_timeout on the migration connection means a statement that cannot get its lock quickly fails fast instead of queueing behind a long query while every other request piles up behind it. This one setting turns a lot of outages into a failed deploy.
05 / The application server
Tuning gunicorn, and why the formula is wrong
Everyone quotes (2 × CPU) + 1. That formula assumes synchronous workers doing CPU-bound work. A Django request is almost never CPU-bound. It spends its life waiting on Postgres and on somebody else's API.
Worker count comes from measured concurrency and your memory ceiling. Nothing else.
gunicorn config.wsgi:application \
--bind 127.0.0.1:8000 \ # never 0.0.0.0, see below
--worker-class gthread \ # threads: the work is I/O, not CPU
--workers 4 \ # RAM ceiling / memory per worker
--threads 4 \ # concurrency inside each worker
--max-requests 1000 \ # recycle, to mask a slow leak
--max-requests-jitter 200 \ # so they do not all restart together
--timeout 30 \ # longer than this belongs in Celery
--graceful-timeout 30 \ # let in-flight requests finish
--access-logfile -Workers are processes, not threads
Each one is a full copy of your application in memory. Four workers at 400MB is 1.6GB before a single request arrives. Count from the box you actually have, then verify under load rather than trusting the arithmetic.
Threads cover the waiting
A sync worker blocked on a slow query serves nobody. gthread lets one process hold several requests that are all waiting on I/O, which is nearly all of them. Usually a bigger win than raising the worker count, and it costs no extra memory.
--preload is not free
It shares memory through copy-on-write and looks like an easy saving. It also breaks graceful reload, and anything opening a connection at import time is now shared across forked children in a way it was never designed for. Turn it on deliberately or not at all.
max-requests is a tourniquet
Recycling workers hides a memory leak rather than fixing it. Use it, because production should be stable while you investigate, then actually go and find the leak.
The timeout is a design statement
If a request legitimately needs more than thirty seconds, it is not a request. Hand it to Celery and return a job id. Raising the gunicorn timeout to accommodate it only moves the outage further out.
Gunicorn should never face the internet
It does not buffer slow clients. One client trickling a request body a byte at a time occupies a worker for as long as it likes, and a few hundred of them take the site down with no real load at all. Bind to localhost, put a buffering proxy in front. This is the most common production Django mistake we find and it is one line of config.
06 / Connections
Pooling advice has changed, and most of the internet has not
Nearly every Django pooling guide you will find predates Django 5.1 and tells you to install PgBouncer. Sometimes that is still right. Often it is now an extra service to run for no reason.
| Your situation | What we would do |
|---|---|
| Django 5.1 or newer, WSGI, one or two app servers | Native connection pooling. No extra service, no extra failure mode. |
| Django 5.1 or 5.2 on ASGI | PgBouncer. Django’s own docs advise against native pooling with ASGI on those versions. |
| Django 6.0 or newer on ASGI | Native again. 6.0 ships an async-aware pool, which is what was missing. |
| Many app servers, or several services sharing one database | PgBouncer. Centralised pooling is the thing it is genuinely better at. |
| Stuck below 5.1 and cannot upgrade yet | CONN_MAX_AGE, and put the upgrade on the roadmap. |
If you do run PgBouncer in transaction mode
Know what you give up: no prepared statements, no advisory locks, and no temporary tables that survive across transactions. Django has to be configured to match, and a codebase that quietly relies on any of those will fail in ways that look random. This is the sort of thing an engineer either has been bitten by or has not.
07 / Getting it onto a server
How we deploy Django
Opinionated, and you are welcome to disagree on the call. The short version is that most Django applications are one server away from being fine, and a lot of teams have bought three layers of infrastructure to avoid admitting it.
One server, until one genuinely is not enough
A modern box runs a Django app with real traffic without complaining. Horizontal scale should be the answer to a problem you can point at in a graph. Until then it is cost and attack surface.
nginx or Traefik, and it matters less than people think
Traefik earns its place when containers come and go, because routing comes off labels and certificates renew themselves. nginx earns its place when the topology is stable and you want a config file you can read. Either is fine. Neither is a reason to run Kubernetes.
WhiteNoise before you reach for a CDN
Serving static files from the app with WhiteNoise is one less moving part and it is genuinely fine for most traffic. Add a CDN when you can show it is needed, not because the tutorial had one.
Docker Compose is a legitimate production answer
On a single host it is readable, reproducible and boring. The interesting engineering in your product is not in your orchestrator.
Ansible when the server stops being explainable
If nobody can say what is installed on that box without logging in, that is the moment. A playbook in the repo means the server can be rebuilt from nothing, which is the only real test of whether anyone understands it.
Health checks that mean something
A check returning 200 whenever Python is alive will cheerfully report health while Postgres is unreachable. Check the dependencies the request path actually needs, and nothing it does not.
The test we hold ourselves to
A new engineer on your team should be able to deploy on their first day, from a written runbook, without asking anyone. If deploying requires a person who knows the incantation, you do not have a deployment process, you have a dependency on one human being. That is a bus factor problem dressed up as infrastructure.
08 / The first month
What we do before we change anything
Arriving at somebody else's Django project and immediately refactoring is how vendors lose clients. The order does not change.
- Week 1
Measure, do not touch
Django version and support status, query counts on the slowest endpoints, the plans behind them, worker saturation, error rates. You get a written list of what is actually costing you, ranked, before we propose anything.
- Week 2
Find out if the tests are real
Not the coverage number, whether the suite would catch a regression. If it would not, that is the honest first piece of work, and we say so rather than build on top of it.
- Week 3
Fix what your team feels
The report that times out, the queue that backs up, the admin page nobody opens any more. Something specific, so the engagement stops being theoretical in week three rather than month three.
- Week 4 on
Earn the structural changes
Schema work, the version upgrade, the deploy pipeline. Only after shipping safely several times, because opinions about your architecture are cheap in week one and expensive to act on.
09 / Rates
What it costs
$4,800–$7,200
per month / one senior Django engineer / full time
Equipment, management and their replacement if they leave are inside that number. Nothing is billed on top, and recruiting is never charged to you.
Moves on seniority, how much technical leadership you need, contract length and on-call cover. Morocco is why the range sits where it does: senior rates here are the lowest of any region Lemon.io tracks in its 2026 data, and the cause is local cost of living rather than a discount on the work. Need several people at once? A dedicated development team in Morocco prices per seat off the same figure.
You are on an unsupported Django
Good starting point. Well-defined work, a real finish line, and a security problem solved rather than a preference satisfied.
You want a rewrite in something else
Usually the wrong call, and we will argue before taking your money. Most Django projects people want to escape have a data model problem, not a framework problem.
You need ten Django engineers next week
We are a small company. We would have to recruit, and you get a real date instead of a comfortable one.
10 / Questions
Straight answers
Can your engineers work on an existing, messy Django codebase?
That is most of the work. A greenfield Django project is the rare engagement. Reading Django somebody else wrote is a specific skill and it is the one we screen for.
Can you upgrade our Django version safely?
Yes, and the order matters. If the test suite is thin, the first weeks are tests, because upgrading with no safety net is how teams end up reverting on a Friday night. Then one version at a time, deprecation warnings raised to errors, both versions green before moving. For most teams the right target today is 5.2 LTS, supported to April 2028.
How do you approach a performance problem?
By measuring before touching anything. The usual finding is query count, not server count. An admin page issuing four hundred queries does not get faster on a bigger instance, it gets more expensive. We read the plan first, then decide.
What is your experience with Django REST Framework?
Serializers are where N+1 queries hide, because the nesting is invisible until you count the queries. We treat a query-count assertion on list endpoints as part of the definition of done.
What about async Django?
Useful for specific I/O-bound paths, not a general speed-up. The ORM is still largely synchronous, so a half-converted codebase often ends up slower and much harder to reason about. We will tell you when it is worth it, which is less often than the blog posts suggest.
Do you deploy on AWS, or somewhere else?
Both. We run production Django on AWS and on Hetzner, and part of the first conversation is usually whether your workload needs what you are currently paying AWS for. For that side of it, see our AWS engineers in Morocco.
How does this compare to hiring a Django developer locally?
Slower to hire and several times the cost, and you carry the employment risk. Against a freelancer, the difference is that we manage the person, cover them when they are ill, and replace them if they leave. That is what the rate buys.
Can I interview them first?
Yes, and please do. Ask the fifty million row question above, then ask them to walk you through a query plan. Those two answers tell you more than an hour of algorithm puzzles.
Bring a slow page
Thirty minutes. Bring the endpoint that times out and we will read the query plan with you on the call. You keep the answer whether or not you hire us.
Also for you
Python engineers across the wider stack / AWS engineers who have been on call / every role we staff