ffmpegpythonvideo-api

FFmpeg in Python

·Javid Jamae·9 min read
FFmpeg in Python

You installed a Python package with "ffmpeg" in the name, ran pip install, and it still can't find the encoder. Or it works locally and dies the moment it hits your Docker image, your Celery worker, or a Lambda function. The gap between "FFmpeg works in my terminal" and "FFmpeg works inside my Python service" is where most of the time goes.

Quick answer: Running FFmpeg in Python means calling the FFmpeg binary from your Python process, normally with subprocess.run(["ffmpeg", "-i", "in.mp4", ...]), because wrapper libraries like ffmpeg-python and MoviePy build that same argument list and shell out to the same executable. The DIY path works fine on a laptop and gets brittle in production, where the binary has to exist in every image and a 10-minute encode pins a worker at 100% CPU. FFmpeg Micro runs the same job as one HTTP call from requests, with no binary to install and no servers to run, on a free tier.

Almost every Python FFmpeg package is a string builder

The thing people expect from "FFmpeg in Python" is a native library that decodes and encodes video in-process. That's not what they get. ffmpeg-python, MoviePy, and the dozens of smaller wrappers assemble a list of command-line arguments and hand it to subprocess. If ffmpeg isn't on the PATH of the process that imports them, they fail with the same error you'd get from the shell.

Two packages are genuine exceptions. PyAV binds libavformat, libavcodec, and libavfilter directly through Cython, so you get frames as Python objects and never spawn a process. imageio-ffmpeg goes the other way and ships a static FFmpeg binary inside its wheels, which is why MoviePy can encode on a machine where nobody ran apt install ffmpeg.

Once you know that, the choice stops being "which library" and becomes "do I want the process or the bindings." For nine out of ten jobs, resize, transcode, concatenate, burn captions, extract audio, the process wins because the FFmpeg CLI documentation is the best documentation in the project and every Stack Overflow answer is already in CLI form.

The subprocess pattern that survives production

Calling FFmpeg from Python well takes about fifteen lines. Pass the command as a list, never a string with shell=True, so filenames with spaces, quotes, or a leading dash can't break the invocation or inject a second command.

import subprocess

cmd = [
    "ffmpeg", "-nostdin", "-y",
    "-i", "input.mp4",
    "-vf", "scale=1080:1920:force_original_aspect_ratio=decrease,"
           "pad=1080:1920:(ow-iw)/2:(oh-ih)/2",
    "-c:v", "libx264", "-preset", "medium", "-crf", "23",
    "-c:a", "aac", "-b:a", "128k",
    "-movflags", "+faststart",
    "output.mp4",
]

proc = subprocess.run(cmd, capture_output=True, text=True, timeout=900)
if proc.returncode != 0:
    raise RuntimeError(proc.stderr[-2000:])

Three details in there are load-bearing. -nostdin stops FFmpeg from swallowing your terminal's stdin, which is the reason a for loop over 50 files processes one and then silently exits. capture_output=True collects stderr, where FFmpeg writes everything including the actual error message, so your exception has something useful in it. timeout=900 means a wedged encode raises TimeoutExpired instead of holding a worker forever.

The -movflags +faststart flag moves the MP4 moov atom to the front of the file. Skip it and your output plays fine in VLC and refuses to start streaming in a browser until it's fully downloaded.

Reading progress without blocking

FFmpeg emits machine-readable progress when you add -progress pipe:1 -nostats, which turns a long encode into a stream you can push to a status field or a websocket.

proc = subprocess.Popen(
    cmd + ["-progress", "pipe:1", "-nostats"],
    stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True,
)
for line in proc.stdout:
    if line.startswith("out_time_ms="):
        seconds = int(line.split("=", 1)[1]) / 1_000_000
        print(f"{seconds:.1f}s encoded")
proc.wait()

The out_time_ms key is misnamed in FFmpeg's own output. It reports microseconds, not milliseconds, so divide by 1,000,000. Divide by 1,000 and your progress bar will finish 1,000 times too early and you'll spend an afternoon blaming your math.

Which Python FFmpeg library should you actually use

The honest ranking depends on whether you need frames or files. If you're moving whole files through standard operations, use subprocess. If you need pixel data in NumPy, use PyAV. Everything else is a convenience layer with a maintenance question attached.

ApproachWhat it doesBest forThe catch
`subprocess` + arg listRuns the system FFmpeg binaryAny file-level operationYou install and version the binary yourself
ffmpeg-pythonBuilds a filter graph, calls `subprocess`Complex filter graphs as codeVersion 0.2.0 dates to 2019 and is effectively unmaintained
MoviePyPython editing API over imageio-ffmpegScripted edits, quick compositesMoviePy 2.0 removed `moviepy.editor`, so old tutorials break
PyAVCython bindings to libav*Frame-level access, no process spawnSteeper API, you manage packets and streams yourself
imageio-ffmpegShips a static binary in the wheelGetting FFmpeg into an image with pip aloneAdds tens of MB and pins a build you don't control
FFmpeg Micro APIRuns the job on managed infrastructureWeb apps, workers, agents, no-code flowsNetwork round trip, and your media leaves the box

ffmpeg-python deserves a specific note because it's the top result for most searches on this topic. It's a good design, the filter-graph API is genuinely nicer than string concatenation, and its last PyPI release is from 2019 with an open issue queue in the hundreds. Building the same arg list yourself costs you ten lines and removes a dependency that nobody is patching.

Common pitfalls when calling FFmpeg from Python

Most FFmpeg-in-Python bugs are environment bugs, not video bugs. They cluster in a few places.

  • PATH differs by process. Your shell sources .zshrc; cron, systemd, and a Gunicorn worker do not. Hardcode the path or resolve it once with shutil.which("ffmpeg") at startup and fail loudly if it's None. The PATH-versus-install diagnosis is the same on every OS.
  • Pipe deadlock. Using Popen with stdout=PIPE and then calling .wait() deadlocks when FFmpeg fills the OS pipe buffer, which is 64 KB on Linux. Use .communicate(), or read the pipe as you go like the progress example above.
  • The Docker image is missing codecs. python:3.12-slim has no FFmpeg at all, and some distro builds ship without libx264 or libfdk_aac. Getting FFmpeg into Docker is less about the Dockerfile than about which build you pulled.
  • Reading stderr only on failure. FFmpeg warns about things it silently works around, like a stream with no audio when your command maps 0:a. Log stderr on success too, at debug level.
  • Two jobs, one CPU. FFmpeg uses every core it can get. Run two encodes on a 2-vCPU box and both take more than twice as long. Cap concurrency at the queue, not in the command.

When FFmpeg does not belong inside your Python app

Video encoding is CPU-bound work with unbounded duration, which makes it a bad neighbor for a web process. A 10-minute 1080p H.264 re-encode at preset medium runs at roughly real-time on a single vCPU, so it can take longer than the file itself. Gunicorn's default worker timeout is 30 seconds. AWS Lambda caps a single invocation at 15 minutes and gives you 512 MB of /tmp unless you raise it to as much as 10,240 MB, and you're paying for the whole encode at function rates.

The usual fix is a Celery or RQ worker on a bigger box, which trades a timeout problem for a capacity problem: the box is idle most of the day and undersized the hour a customer uploads 200 clips. That's where offloading beats scaling. The FFmpeg API takes the job over HTTP, runs it on managed encoders, and hands you back a URL, so your Python process stays a request handler instead of becoming a media microservice.

The same operation as one API call

The call shape from Python is a plain HTTP request. You submit a job, you get an id back, and you either poll it or let a webhook tell you it's finished, then you download the output.

import os, time, requests

API = "https://api.ffmpeg-micro.com"
headers = {"Authorization": f"Bearer {os.environ['FFMPEG_MICRO_API_KEY']}"}

# Field names for each operation are in the docs: ffmpeg-micro.com/docs
job = requests.post(f"{API}/jobs", json={...}, headers=headers).json()

while True:
    status = requests.get(f"{API}/jobs/{job['id']}", headers=headers).json()
    if status["status"] in ("completed", "failed"):
        break
    time.sleep(2)

open("output.mp4", "wb").write(requests.get(status["output_url"]).content)

No binary in the image, no PATH check, no worker pinned at 100%. The same endpoint backs the n8n, Make, and Zapier integrations and the MCP server, so a job you prototype in Python is the same job an agent can call later. If you'd rather see the transformation before writing any code, the playground runs it in the browser.

FAQ

Do I need FFmpeg installed to use ffmpeg-python?

Yes. ffmpeg-python builds a command line and executes the system ffmpeg binary, so pip install ffmpeg-python alone gives you nothing runnable. MoviePy is the exception among the popular wrappers because it depends on imageio-ffmpeg, whose wheels bundle a static FFmpeg build.

Is subprocess or a wrapper library faster?

A subprocess call and a wrapper library encode at the same speed, because both run the identical FFmpeg process with the identical arguments. Wrapper overhead is a few milliseconds of Python against an encode measured in seconds or minutes. Pick based on maintenance and readability, not speed.

How do I get FFmpeg output frames into NumPy?

PyAV is the direct route, since it decodes in-process and gives you frame.to_ndarray(format="rgb24") without a subprocess. The subprocess alternative is piping -f rawvideo -pix_fmt rgb24 pipe:1 into numpy.frombuffer, which works but means you compute the frame size yourself from the stream dimensions.

Why does my FFmpeg loop in Python only process the first file?

FFmpeg reads from stdin by default and consumes the rest of your input when it's running inside a loop. Adding -nostdin to every command fixes it. Passing stdin=subprocess.DEVNULL to subprocess.run does the same thing from the Python side.

Can I run FFmpeg on AWS Lambda from Python?

You can, using a Lambda layer or a container image that includes a static FFmpeg build, and it works well for short clips. The hard limits are a 15-minute maximum invocation and /tmp storage that starts at 512 MB, so anything long or large needs either a configured ephemeral storage bump or a different runtime entirely.

Wire up the Python client above and the first jobs run on the free tier, so you can compare your local encode against the hosted one before you decide what belongs in your image. Sign up free and keep the subprocess code as a fallback.

About Javid Jamae

Founder & CEO at FFmpeg Micro

Javid is a software engineer, author, and entrepreneur with over 25 years of professional software development experience across enterprise, startup, and consulting environments. He founded FFmpeg Micro to make video processing accessible to developers through a simple, automation-first REST API.

Software EngineeringVideo ProcessingFFmpegCloud ArchitectureAPI DesignAutomation

Ready to process videos at scale?

Start using FFmpeg Micro's simple API today. No infrastructure required.

Get Started Free