#1886·thumbor

[Bug] PILLOW_PRESERVE_INDEXED_MODE drops transparency on GIF output from a palette source

Author: nlemoineCreated Aug 27, 2026Updated Aug 27, 2026

Thumbor Request URL

http://example.com/unsafe/100x100/transparent.gif

Expected Behaviour

The transparent pixels of a palette source should still be transparent after a resize. Requesting a GIF through thumbor should give back a GIF with its transparency index intact, the way the same request does for PNG or WebP output.

Actual Behaviour

The GIF comes back fully opaque and the formerly transparent pixels render black.

None of the fixtures in tests/fixtures/images carries a transparency index, so here is a generated source. It is a 200x200 GIF, top half opaque red, bottom half fully transparent:

python
from PIL import Image
im = Image.new("P", (200, 200))
im.putpalette([255, 0, 0] + [0, 0, 0] * 255)
im.paste(1, (0, 100, 200, 200))
im.save("transparent.gif", transparency=1)

Serve that and ask for a plain /unsafe/100x100/ resize, no filters:

PILLOW_PRESERVE_INDEXED_MODE = True (the default)
  out: GIF  info['transparency']=None  fully transparent px = 0 / 10000

PILLOW_PRESERVE_INDEXED_MODE = False
  out: GIF  info['transparency']=1     fully transparent px = 4800 / 10000

Comparing the two renders pixel by pixel, the pixels that are transparent in the second are all (0, 0, 0, 255) in the first.

I mapped which combinations are affected, since it is narrower than I first assumed. Five sources, each 200x200 with the same half-transparent layout, through four output formats:

PILLOW_PRESERVE_INDEXED_MODE = True (the default)
source                     native    gif     png     webp
GIF (P + index)            LOST      LOST    kept    kept
PNG-8 (P + index)          kept      LOST    kept    kept
PNG-8 (P + tRNS palette)   kept      LOST    kept    kept
PNG-32 (RGBA)              kept      kept    kept    kept
WebP (RGBA)                kept      kept    kept    kept

PILLOW_PRESERVE_INDEXED_MODE = False
  every cell above is kept

So it takes all three of a palette source, GIF output, and the setting at its default. A truecolour source is fine even into GIF, and PNG output is fine even from a palette source.

I think it might be caused by the quantize() call in the PILLOW_PRESERVE_INDEXED_MODE branch of PILEngine.read(), since that is what the affected cells have in common and turning the setting off avoids it. But I do not know the codebase well enough to say whether that is where it should be handled, and the PNG column suggests the writer matters too, since the same branch runs there without losing anything.

I have no fix to propose, I work at the PHP integration layer rather than in Pillow. Our workaround is PILLOW_PRESERVE_INDEXED_MODE = False, which costs some file size (Pillow's own quantization instead of libimagequant) but keeps the alpha.

One note if you add a regression fixture: paletted-transparent.png is not a good candidate. It carries 16083 partially transparent pixels, and GIF has only 1 bit of alpha, so it comes back opaque under both settings and cannot show the difference. A source with binary alpha, like the generated one above, separates them cleanly.

Operating System

Official Docker image ghcr.io/thumbor/thumbor:latest (Debian 11 bullseye, thumbor 7.7.7, Pillow 10.4.0), host macOS 15.7.3.

Your thumbor.conf

python
SECURITY_KEY = "dummy-key"
ALLOW_UNSAFE_URL = True
LOADER = "thumbor.loaders.http_loader"
# PILLOW_PRESERVE_INDEXED_MODE left at its True default for the failing case,
# set to False for the working one. Nothing else is set.