Fix Django Code Changes Not Showing After Deploy
Fix deploys that seem to do nothing: Gunicorn not reloaded, old processes still running, wrong release directory, cached templates, and stale assets.
Problem statement
You deploy a change, the deploy script reports success, but the site still behaves like the old version: a fixed bug is still there, a new page returns 404, or the new CSS does not apply. Sometimes the change appears for some requests and not others.
In development, runserver reloads code automatically. In production, nothing does. Gunicorn workers import your code once when they start and keep running that version in memory until they are replaced. On top of that, several layers can serve old content: browser caches, CDNs, static file directories, and template caches.
The fix is to find which layer is still serving the old version, then make the deploy process update it explicitly.
Quick answer
- Confirm the new code is on disk where the running process loads it from:
readlink /proc/<gunicorn-pid>/cwdandgit log -1in that directory. - Restart or gracefully reload Gunicorn after every deploy:
systemctl restart gunicornorsystemctl reload gunicorn(HUP). - Check for multiple Gunicorn masters, including a manually started one still serving old code.
- For Docker, make sure the container was recreated from the new image, not just restarted.
- For CSS and JavaScript, run
collectstaticand use hashed filenames; purge CDN caches for unhashed assets. - Rule out browser and CDN caching with
curland cache headers. - Expose a version endpoint or header so you can confirm which release is serving.
Step-by-step solution
1. Identify what is actually running
systemctl status gunicorn --no-pager | head -15
ps -eo pid,ppid,lstart,cmd | grep "[g]unicorn"
lstart shows when each process started. If the master and workers started before your deploy, they are still running the old code.
Check which directory the processes use:
MASTER=$(systemctl show -p MainPID --value gunicorn)
sudo readlink /proc/$MASTER/cwd
sudo cat /proc/$MASTER/cmdline | tr '\0' ' '; echo
Then confirm what code is in that directory:
cd "$(sudo readlink /proc/$MASTER/cwd)"
git log -1 --oneline
2. Reload or restart Gunicorn
Gunicorn does not watch files in production. After deploying code:
sudo systemctl restart gunicorn
or, for a graceful reload that keeps the listening socket and replaces workers as they finish current requests:
sudo systemctl reload gunicorn
This requires the unit to define ExecReload=/bin/kill -s HUP $MAINPID. Check with systemctl cat gunicorn.
Two caveats:
--preload: with--preload, the application is imported in the master process. AHUPreload re-reads the configuration and starts new workers, but they fork from a master that already imported the old code. Use a full restart when running with--preload.- Symlinked releases: if
WorkingDirectorypoints at acurrentsymlink that your deploy switches, the master's working directory stays resolved to the old target until the master restarts. AHUPalone may keep serving the old release; restart the service after switching the symlink, or use Gunicorn'sUSR2binary upgrade procedure.
3. Look for a second Gunicorn
A Gunicorn started manually during debugging (in tmux, with nohup, or by an old Supervisor configuration) can keep serving traffic while your systemd service runs new code on a different socket or fails to bind.
sudo ss -ltnp | grep -E ':8000|gunicorn'
sudo ss -xlp | grep gunicorn
Compare the socket or port in the Nginx proxy_pass with the one your service uses. Stop any process that is not managed by your intended unit.
4. Docker: recreate containers
docker compose restart web restarts the existing container, which still uses the image it was created from. Pull or build and recreate:
docker compose pull web # or: docker compose build web
docker compose up -d web
docker compose ps
docker inspect --format '{{.Config.Image}} {{.Image}}' $(docker compose ps -q web)
Compare the image ID with the one you just built or pushed. If you tag every build as latest, it is easy to deploy a cached older latest; use immutable tags such as the Git commit SHA.
5. Templates and cached loaders
With DEBUG = False, Django enables the cached template loader by default. Templates are compiled once per process and kept in memory. New template files are picked up only after workers restart, which a proper restart handles.
If you use a cache framework for pages or fragments (cache_page, {% cache %}, per-site cache middleware), old rendered HTML remains in Redis or Memcached until it expires. After deploys that change rendered output, bump a cache version or key prefix, or clear specific keys.
6. Static files: CSS and JavaScript
Symptoms: HTML changes appear, but styling or scripts are old.
collectstaticnot run: new or changed assets never reachedSTATIC_ROOT. Run it on every deploy.- No hashed filenames: a file named
app.csscached by browsers or a CDN for a long time stays stale. UseManifestStaticFilesStorageor WhiteNoise's compressed manifest storage so changed files get new URLs. - WhiteNoise: it indexes
STATIC_ROOTat startup; restart aftercollectstatic. - CDN: purge unhashed files, or rely on hashed names so purging is unnecessary.
Check what the browser receives:
curl -s https://www.example.com/ | grep -o '/static/[^"]*\.css' | head -3
curl -sI https://www.example.com/static/css/app.3f2a1b9c.css | grep -i -E "^HTTP|cache-control|age|cf-cache-status|x-cache"
A response with an Age header or a cache status of HIT came from a cache layer.
7. Rule out browser caching
Test in a private window, or with curl, which has no cache. If curl shows the new version but your browser does not, it is a local cache. For HTML pages, make sure you are not sending long cache lifetimes on dynamic responses (for example a misconfigured Cache-Control header set in Nginx for location /).
8. Add a version marker
Make "which code is running" answerable in one request. Write the release identifier into the environment at deploy time:
echo "APP_RELEASE=$(git rev-parse --short HEAD)" | sudo tee /etc/myapp/release.env
[Service]
EnvironmentFile=/etc/myapp/env
EnvironmentFile=/etc/myapp/release.env
Expose it in a response header:
import os
class ReleaseHeaderMiddleware:
def __init__(self, get_response):
self.get_response = get_response
self.release = os.environ.get("APP_RELEASE", "unknown")
def __call__(self, request):
response = self.get_response(request)
response["X-App-Release"] = self.release
return response
curl -sI https://www.example.com/ | grep -i x-app-release
After a deploy, the header must show the new commit. If some requests show the old value, more than one process or server is serving traffic with different code. If revealing the commit publicly is a concern, expose it only on an internal health endpoint instead.
9. Make the deploy script verify itself
End every deploy by checking the running release rather than trusting the script:
expected=$(git rev-parse --short HEAD)
for i in $(seq 1 10); do
actual=$(curl -sI https://www.example.com/healthz/ | awk -F': ' 'tolower($1)=="x-app-release"{print $2}' | tr -d '\r')
[ "$actual" = "$expected" ] && echo "release $actual is live" && exit 0
sleep 3
done
echo "expected $expected, got ${actual:-nothing}" >&2
exit 1
Verification check:
- the release header matches the deployed commit for repeated requests
- Gunicorn processes started after the deploy time
- hashed static asset URLs change when their content changes
- only one Gunicorn master serves the site per server
Explanation
A Python web process is a snapshot of the code it imported at startup. Deploying files to disk changes nothing until processes are replaced, and several caches (compiled templates, rendered fragments, static files, CDNs, browsers) can each hold older output. Deploys that restart processes deterministically, use content-hashed assets, and verify the live release version remove the guesswork.
Edge cases / notes
Multiple servers behind a load balancer
If only some requests show the new version, one server may have failed its deploy. Check the release header across backends, for example by requesting each server directly through its private address.
Bytecode files
Stale .pyc files are almost never the cause, because Python checks source timestamps. They can matter if deploys preserve old modification times (some copy tools do). Deploying into fresh release directories avoids the question.
Celery workers
Background tasks run in Celery workers, which also keep old code until restarted. Restart workers as part of every deploy that changes task code.
Common mistakes
- Pulling new code without restarting Gunicorn.
- Using
docker compose restartinstead of recreating containers from the new image. - Relying on
HUPreloads with--preloador symlink switches. - Serving unhashed static filenames with long cache lifetimes.
Next steps
- Remove stray processes with Fix Gunicorn "Address already in use".
- Make assets cache-safe with Long-Lived Caching for Django Static Files.
- Deploy without dropping requests with How to Set Up Zero-Downtime Deployments for Django.
- Standardize checks with the Post-Deploy Verification Checklist for Django.
FAQ
Why doesn't Gunicorn pick up my Django code changes?
Gunicorn workers keep the code they imported at startup. Restart the service, or send HUP for a graceful reload, after every deploy.
Should I use systemctl restart or reload for Gunicorn?
reload (HUP) replaces workers gracefully and keeps the socket, which is ideal for routine deploys without --preload. Use restart when running with --preload, after switching release symlinks, or when changing Gunicorn settings that require a new master.
Why is my new CSS not showing after deploying Django?
collectstatic may not have run, files may not use hashed names, or a CDN or browser is serving a cached copy. Check the asset URL in the HTML and its response headers.
How can I tell which version of my Django app is deployed?
Add the release identifier to the environment during deploy and expose it in a response header or health endpoint, then compare it with the commit you deployed.