Animated images: GIF and WebP frames
Iterate frames of an animation, assemble your own, control duration and looping, and work around the palette limits of GIF.
Reading frames
from PIL import Image, ImageSequence
im = Image.open("loading.gif")
print(im.n_frames, im.info.get("loop"), im.info.get("duration"))
frames = []
for i, frame in enumerate(ImageSequence.Iterator(im)):
print(i, frame.mode, frame.info.get("duration"))
frames.append(frame.convert("RGB").copy()) # copy: the iterator reuses the object
# random access is also available
im.seek(3)
print(im.info.get("duration"), im.size)seek()moves to a frame;n_framestells you how many there are. Reading a frame is lazy, so copy it if you want to keep it.durationis in milliseconds and may be missing per frame, in which case the base frame's value applies.loop=0means loop forever; a missing loop value means play once, which browsers often ignore.
Writing frames
from PIL import Image, ImageDraw
size = (320, 240)
frames = []
for i in range(24):
frame = Image.new("RGB", size, "white")
d = ImageDraw.Draw(frame)
d.ellipse((20 + i * 10, 100, 60 + i * 10, 140), fill="crimson")
frames.append(frame)
# modern format: WebP keeps full colour and supports alpha
frames[0].save("anim.webp", save_all=True, append_images=frames[1:],
duration=40, loop=0, quality=80, method=6)
# GIF needs palette frames and a smaller colour count
pal_frames = [f.convert("P", palette=Image.Palette.ADAPTIVE, colors=128) for f in frames]
pal_frames[0].save("anim.gif", save_all=True, append_images=pal_frames[1:],
duration=40, loop=0, optimize=True, disposal=2)| Format | Colours | Alpha | Notes |
|---|---|---|---|
| GIF | 256 per frame | 1 bit | Universally supported, visibly banded on gradients |
| WebP animated | Full 24-bit | 8-bit | Much smaller and better looking; universal in browsers now |
| APNG | Full 24-bit | 8-bit | Supported by most browsers; save with save_all=True |
| MP4 | Full | n/a | Use a video tool; Pillow does not encode video |
Pitfalls
# frames must share a size: normalise before saving
target = frames[0].size
frames = [f if f.size == target else f.resize(target, Image.Resampling.LANCZOS) for f in frames]
# disposal=2 clears each frame to the background before the next one draws
frames[0].save("clean.gif", save_all=True, append_images=frames[1:],
duration=100, loop=0, disposal=2)💡
GIF durations are quantised to hundredths of a second, so a duration of 40 ms is typically stored as 40 ms and rendered slower by many viewers. Do not rely on GIF for timing-sensitive animation; use WebP or an actual video file.
FAQ
Why do my GIF frames look wrong or overlap?
Frames smaller than the canvas are composited according to the disposal and offset metadata. Convert each frame to a full-size image and use
disposal=2 so every frame starts from the background.Can I extract a single frame as a still?
Yes:
im.seek(n) then im.convert("RGB").save("frame.png"). Remember the frame may be partial, so seek and then read the whole canvas rather than assuming frame-local coordinates.Related
Opening, inspecting and saving images Drawing and converting formats
Last refreshed 2026-09-18.