MP4-to-GIF is one of those tasks that looks one-line trivial but has hidden traps: GIF only supports 256 colors, frame rates above 15-20fps explode file size, and naive conversion produces banding and dithering artifacts. The trick is the two-pass FFmpeg palette workflow.

Method 1: moviepy (the easy default)

moviepy wraps FFmpeg and exposes a Pythonic API. It handles trimming, resizing, and GIF export in a few lines.

pip install moviepy
from moviepy.editor import VideoFileClip

def mp4_to_gif(in_path: str, out_path: str, start: float = 0, end: float = 5, fps: int = 15, scale: float = 0.5) -> None:
    with VideoFileClip(in_path) as clip:
        sub = clip.subclip(start, end).resize(scale)
        sub.write_gif(out_path, fps=fps, program="ffmpeg")

mp4_to_gif("demo.mp4", "demo.gif", start=0, end=5, fps=15, scale=0.5)

Three knobs control file size:

  • fps — 12-15 is usually enough for screen recordings; 20-24 for action footage. Each fps doubling roughly doubles file size.
  • scale — 0.5 (half resolution) cuts file size by ~75%. GIFs above ~720px wide are usually too big.
  • duration — keep clips under 10 seconds for typical use. Above that, consider WebP/MP4 instead.

moviepy's program="ffmpeg" flag is important — its default ImageMagick path produces lower-quality GIFs.

Method 2: ffmpeg-python (palette-optimized for max quality)

For the best possible GIF quality, you want to use FFmpeg's palettegen + paletteuse two-pass workflow. ffmpeg-python wraps this cleanly.

pip install ffmpeg-python
apt install ffmpeg  # or brew install ffmpeg
import ffmpeg
import tempfile
from pathlib import Path

def mp4_to_gif(in_path: str, out_path: str, start: float = 0, duration: float = 5, fps: int = 15, width: int = 480) -> None:
    with tempfile.TemporaryDirectory() as tmp:
        palette = Path(tmp) / "palette.png"

        # Pass 1: generate optimal 256-color palette
        (
            ffmpeg
            .input(in_path, ss=start, t=duration)
            .filter("fps", fps=fps)
            .filter("scale", width, -1, flags="lanczos")
            .filter("palettegen", stats_mode="diff")
            .output(str(palette))
            .overwrite_output()
            .run(quiet=True)
        )

        # Pass 2: encode GIF using the palette
        in_video = ffmpeg.input(in_path, ss=start, t=duration)
        in_palette = ffmpeg.input(str(palette))
        (
            ffmpeg
            .filter([in_video, in_palette], "paletteuse", dither="sierra2_4a")
            .output(out_path, vf=f"fps={fps},scale={width}:-1:flags=lanczos")
            .overwrite_output()
            .run(quiet=True)
        )

mp4_to_gif("demo.mp4", "demo.gif", start=0, duration=5, fps=15, width=480)

This produces visibly cleaner GIFs than moviepy's default — fewer dithering artifacts, sharper edges, smaller files. The sierra2_4a dither algorithm is a good middle ground; for hard-edge graphics try none; for photos try floyd_steinberg.

Method 3: ChangeThisFile API (no FFmpeg dependency)

If you don't want to install FFmpeg (~150MB) or deal with codec licensing, the API does the conversion server-side. Get a free API key.

import requests

API_KEY = "ctf_sk_your_key_here"

def mp4_to_gif(in_path: str, out_path: str) -> None:
    with open(in_path, "rb") as f:
        response = requests.post(
            "https://changethisfile.com/v1/convert",
            headers={"Authorization": f"Bearer {API_KEY}"},
            files={"file": f},
            data={"source": "mp4", "target": "gif"},
            timeout=120,
        )
    response.raise_for_status()
    with open(out_path, "wb") as out:
        out.write(response.content)

mp4_to_gif("demo.mp4", "demo.gif")

The API uses the same FFmpeg palette-optimization workflow as method 2, so quality is equivalent without you needing to install or maintain FFmpeg.

When to use each

ApproachBest forTradeoff
moviepyQuick scripts, easy API, you don't need top qualitySlower than raw FFmpeg, default settings produce mediocre GIFs
ffmpeg-pythonMax quality, full control, batch jobsNeed FFmpeg installed; more verbose
ChangeThisFile APISkip the FFmpeg dependency, multi-language teams, edge runtimesNetwork call, file size limit (25MB free)

CLI alternative: raw FFmpeg

The Python wrappers ultimately call FFmpeg. If you're doing one-off conversions, the bash version is the same workflow:

# Pass 1: palette
ffmpeg -y -ss 0 -t 5 -i demo.mp4 -vf "fps=15,scale=480:-1:flags=lanczos,palettegen=stats_mode=diff" palette.png

# Pass 2: encode using palette
ffmpeg -ss 0 -t 5 -i demo.mp4 -i palette.png -lavfi "fps=15,scale=480:-1:flags=lanczos[v];[v][1:v]paletteuse=dither=sierra2_4a" out.gif

This is the same algorithm as method 2 but in shell form. Use it for one-offs; switch to Python when conversion is part of a larger pipeline.

Production tips

  • Aim for under 10 seconds. A 1080p 30fps GIF of 10 seconds can hit 50MB+. Longer clips should be MP4 or WebP.
  • Use width=480 for embeds. Common landing-page video embeds are 480-720px wide. Going wider just bloats file size without visible benefit at typical view sizes.
  • Consider WebP-animated as a GIF replacement. Files are 25-50% smaller at the same quality. Supported in every browser. The ChangeThisFile API supports MP4-to-WebP-animated via target=webp.
  • FFmpeg palette work is CPU-bound. A 5-second 1080p clip takes ~3-8 seconds on a typical server. For batch jobs, parallelize with concurrent.futures or ProcessPoolExecutor.

For quality, use the ffmpeg-python palette workflow. For convenience, moviepy. For deployment without FFmpeg, the API. Free tier gives 100 conversions/month.