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
| Approach | Best for | Tradeoff |
|---|---|---|
| moviepy | Quick scripts, easy API, you don't need top quality | Slower than raw FFmpeg, default settings produce mediocre GIFs |
| ffmpeg-python | Max quality, full control, batch jobs | Need FFmpeg installed; more verbose |
| ChangeThisFile API | Skip the FFmpeg dependency, multi-language teams, edge runtimes | Network 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.