Create: frames[0].save("out.gif", save_all=True, append_images=frames[1:], duration=100, loop=0).
Read: for frame in ImageSequence.Iterator(img). To change an animation, process every frame and save them
all again; a single resize() keeps only the first frame.
Create an animation from frames
from PIL import Image
photo = Image.open("photo.jpg")
frames = []
for i in range(16): # a slow zoom towards the rocket
zoom = 1 + i * 0.06
w, h = 480 / zoom, 320 / zoom
box = (700 - w, 560 - h, 700 + w, 560 + h) # region to take from the photo
frames.append(photo.resize((360, 240), Image.LANCZOS, box=box))
frames[0].save("zoom.gif", save_all=True, append_images=frames[1:], duration=90, loop=0)
frames[0].save("zoom.webp", save_all=True, append_images=frames[1:], duration=90, loop=0, quality=75)
import os
print(f"GIF: {os.path.getsize('zoom.gif') / 1024:6.0f} KB")
print(f"WebP: {os.path.getsize('zoom.webp') / 1024:6.0f} KB")
GIF: 783 KB
WebP: 270 KB
save_all=Truewrites all frames; without it you get a still image of the first frame.durationis milliseconds per frame, one number for all frames or a list with one per frame. GIF stores it in hundredths of a second, and most browsers play frames of 10 ms or less at 100 ms, so use at least 20 ms.loop=0repeats forever. Leave it out and the animation plays once.
The WebP is about a third of the GIF's size here, and looks better: GIF allows only 256 colours per frame, so photographic frames are reduced to a palette and dithered. Every current browser plays animated WebP. Use GIF when the destination requires it (some chat apps and email clients).
Read frames, count and durations
from PIL import Image, ImageSequence
gif = Image.open("spinner.gif")
print("animated:", gif.is_animated, " frames:", gif.n_frames, " loop:", gif.info.get("loop"))
durations = [frame.info["duration"] for frame in ImageSequence.Iterator(gif)]
print("durations (ms):", durations, " total:", sum(durations), "ms")
gif.seek(5) # jump to frame 5 (0-based)
print("frame", gif.tell(), "mode", gif.mode, gif.size)
animated: True frames: 12 loop: 0
durations (ms): [80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80] total: 960 ms
frame 5 mode RGB (240, 160)
Pillow opens a GIF on its first frame in mode P; after a seek() frames are usually returned as
RGB or RGBA, because later frames may draw on top of earlier ones with their own palettes. Convert
each frame to one mode before processing, as in the next examples.
Extract every frame
from pathlib import Path
from PIL import Image, ImageSequence
Path("frames").mkdir(exist_ok=True)
with Image.open("spinner.gif") as gif:
for i, frame in enumerate(ImageSequence.Iterator(gif)):
frame.convert("RGBA").save(f"frames/frame_{i:03d}.png")
print(sorted(p.name for p in Path("frames").iterdir())[:3], "...", len(list(Path("frames").iterdir())), "files")
['frame_000.png', 'frame_001.png', 'frame_002.png'] ... 12 files
Resize an animated GIF (without losing the animation)
The obvious version silently drops all but the first frame:
from PIL import Image
gif = Image.open("spinner.gif")
gif.resize((120, 80)).save("small.gif")
print("frames before:", gif.n_frames, " after:", getattr(Image.open("small.gif"), "n_frames", 1))
frames before: 12 after: 1
Resize each frame and save them together, carrying over the durations and loop setting:
from PIL import Image, ImageSequence
def resize_animation(src, dst, size):
with Image.open(src) as im:
frames, durations = [], []
for frame in ImageSequence.Iterator(im):
durations.append(frame.info.get("duration", 100))
f = frame.convert("RGBA")
f.thumbnail(size, Image.LANCZOS)
frames.append(f)
frames[0].save(dst, save_all=True, append_images=frames[1:], duration=durations,
loop=im.info.get("loop", 0), disposal=2)
resize_animation("spinner.gif", "small.gif", (120, 120))
out = Image.open("small.gif")
print("frames:", out.n_frames, " size:", out.size, " duration:", out.info["duration"])
frames: 12 size: (120, 80) duration: 80
The same pattern (iterate, process, save all) applies to cropping, watermarking or converting an animation.
Transparent GIFs and the trail problem
GIF frames are drawn on top of each other. By default, the previous frame stays underneath, so a moving shape on a
transparent background leaves a trail. disposal=2 clears each frame's area before the next one is drawn:
from PIL import Image, ImageDraw
frames = []
for i in range(8): # a dot moving right on a transparent background
f = Image.new("RGBA", (120, 120), (0, 0, 0, 0))
ImageDraw.Draw(f).ellipse((10 + i * 8, 40, 50 + i * 8, 80), fill=(37, 99, 235, 255))
frames.append(f)
frames[0].save("trail.gif", save_all=True, append_images=frames[1:], duration=100, loop=0)
frames[0].save("clean.gif", save_all=True, append_images=frames[1:], duration=100, loop=0, disposal=2)
for name in ["trail.gif", "clean.gif"]:
im = Image.open(name)
im.seek(7)
print(f"{name:<10} last frame, pixel left of the dot: {im.convert('RGBA').getpixel((20, 60))}")
trail.gif last frame, pixel left of the dot: (37, 99, 235, 255)
clean.gif last frame, pixel left of the dot: (0, 0, 0, 0)
In trail.gif the dot's earlier positions are still visible on the last frame (the pixel is opaque blue); in
clean.gif it is transparent. Opaque, full-frame animations like the zoom above do not need this, because every
frame covers the previous one completely.
Related
The outputs and images on this page are the real results of running every example with Pillow 12.3.0 on Python 3.13 (Linux) on 2026-10-15. Every example is also re-run automatically on macOS (Darwin 25.6.0, Pillow 12.3.0), Linux (Linux 6.17.0-1022-azure, Pillow 12.3.0), Windows (Windows 2025Server, Pillow 12.3.0); the line under each example shows the result per operating system. The DejaVu Sans font files sit next to each script so font examples behave the same everywhere. Sample photos: NASA/Sam Lott, public domain. Spotted something wrong? Tell us.