2025, Oct 20 16:00

Wand/ImageMagick delegate missing in Django after Ubuntu 24 LTS upgrade: fix Ghostscript PATH in gunicorn

Fix Django PDF-to-PNG errors on Ubuntu 24 LTS: Wand/ImageMagick 'delegate missing' because Ghostscript isn’t on PATH. Update gunicorn PATH or call Ghostscript.

After a server migration from Ubuntu 22 LTS to 24 LTS, a long-running Django feature for rendering a PNG preview of the first page of a PDF started to fail in production. The same conversion worked on the command line and even inside the Django shell within the same virtual environment, but broke when triggered via the admin interface behind gunicorn and nginx. The runtime error coming from Wand and ImageMagick was the classic delegate failure.

Problem in context

The application reads a PDF, rasterizes only the first page, and returns a PNG blob. The core logic looks straightforward and has been reliable for years. The environment now uses Ubuntu 24 LTS, Python 3.12.3, Django 5.2.4, Wand 0.6.13, nginx 1.24.0, and gunicorn 23.0.0, with no Docker involved. On upload, the process fails with an ImageMagick read error complaining about a missing delegate.

MagickReadImage returns false, but did not raise ImageMagick exception. This can occur when a delegate is missing, or returns EXIT_SUCCESS without generating a raster.

Minimal code that shows the failure

The heart of the preview generation uses Wand and ImageMagick to get the first PDF page and make a PNG blob. The logic is simple and unchanged; only local names differ here:

from wand.image import Image, Color

def pdf_preview_bytes(src_path):
    with Image(filename=src_path) as doc_img:
        with Image(doc_img.sequence[0]) as page_zero:
            with page_zero.convert('png') as png_page:
                png_page.background_color = Color('white')
                png_page.alpha_channel = 'remove'
                return png_page.make_blob()

What is actually going wrong

Wand is a binding around ImageMagick. ImageMagick, in turn, delegates PDF rasterization to Ghostscript. On the upgraded system, running conversions directly works. Ghostscript converts the PDF to PNG just fine. ImageMagick’s convert also succeeds. The same Wand snippet succeeds in the Django shell in the project’s venv. However, the admin-triggered code behind gunicorn fails with a delegate error. The key clue is that Ghostscript does not appear as a delegate in ImageMagick’s configuration output in that runtime, and the error message explicitly mentions a missing delegate. The root cause is that gunicorn was started with a restricted PATH that included only the virtual environment’s bin directory and did not include /usr/bin, where gs resides. As a result, ImageMagick could not find Ghostscript at runtime when invoked through Wand in the web worker process.

PDF-related policy was already permissive enough, and direct CLI conversions proved the toolchain itself was installed and functional. The mismatch was purely environmental between interactive shells and the service unit.

Fix 1: make Ghostscript discoverable in the gunicorn service

The resolution is to ensure the gunicorn process inherits a PATH that includes the system’s Ghostscript binary directory. If you manage gunicorn via systemd, add /usr/bin to PATH in the service configuration (while keeping the virtualenv bin first), then restart the service. After that change, the existing Wand code runs as before.

[Service]
Environment="PATH=/path/to/venv/bin:/usr/bin"

Once gunicorn is restarted with this environment, ImageMagick finds Ghostscript, the delegate chain is intact, and the earlier Wand code returns the expected PNG bytes without modification.

Fix 2: bypass delegates by calling Ghostscript directly

As an alternate approach that avoids any delegate discovery issues, you can invoke Ghostscript directly and return the PNG bytes. This mirrors the original behavior by producing a first-page PNG with a white background and no alpha, and it drops in where a blob was previously returned.

import os
import subprocess
import tempfile

# Ghostscript options emulate the original behavior
_GS_FLAGS = "-dSAFER -sDEVICE=png16m -r120 -dFirstPage=1 -dLastPage=1 -dGraphicsAlphaBits=4 -dTextAlphaBits=4 -o"

def pdf_cover_blob(pdf_path, gs_exec="gs"):
    with tempfile.TemporaryDirectory() as tmp_dir:
        out_png = os.path.join(tmp_dir, "page.png")
        cmd = [gs_exec] + _GS_FLAGS.split() + [out_png, pdf_path]
        subprocess.run(cmd, check=True)
        with open(out_png, "rb") as fh:
            return fh.read()

This keeps the return type the same as png_page.make_blob() and can be used as a fallback when you prefer to avoid any dependency on ImageMagick’s delegate configuration at runtime.

Why this matters

Services started by process managers like systemd often have a tighter or different environment than your shell. That difference is easy to miss when troubleshooting, especially after an OS upgrade that changes package paths or delegates. PDF rendering in Wand depends not just on the Python package but on ImageMagick’s ability to find Ghostscript. If the worker process has a truncated PATH, you will see vague “delegate missing” errors even when all components are installed and work perfectly on the command line.

Practical takeaways

If a Wand + ImageMagick PDF conversion fails only in production behind gunicorn and nginx but succeeds in your shell and in the Django shell, check the runtime environment first. Make sure the gunicorn service PATH includes /usr/bin so Ghostscript can be resolved. If you want to decouple this path entirely from delegate configuration, a direct Ghostscript call via subprocess that returns the PNG bytes is a robust alternative. Either approach restores the original behavior without altering your application’s logic or output.

The article is based on a question from StackOverflow by Geoff and an answer by K J.