Short answer

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

make_animation.py
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")
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
GIF:     783 KB
WebP:    270 KB
An animation slowly zooming into the rocket on the launch pad at sunset
Real output: the animated WebP produced by the script.

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

read_gif.py
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)
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
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

extract_frames.py
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")
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
['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:

resize_gif.py
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))
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
frames before: 12  after: 1

Resize each frame and save them together, carrying over the durations and loop setting:

resize_gif.py
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"])
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
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:

transparent_gif.py
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))}")
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
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.

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.