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_frames tells you how many there are. Reading a frame is lazy, so copy it if you want to keep it.
  • duration is in milliseconds and may be missing per frame, in which case the base frame's value applies.
  • loop=0 means 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)
FormatColoursAlphaNotes
GIF256 per frame1 bitUniversally supported, visibly banded on gradients
WebP animatedFull 24-bit8-bitMuch smaller and better looking; universal in browsers now
APNGFull 24-bit8-bitSupported by most browsers; save with save_all=True
MP4Fulln/aUse 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.

Opening, inspecting and saving images Drawing and converting formats

Last refreshed 2026-09-18.