Creating 3D Animations (GIFs) with Python Matplotlib

Learn how to create and save 3D data animations as GIF files using Python's Matplotlib and NumPy. Covers how FuncAnimation actually works, what blitting does (and doesn't do) on save, measured PillowWriter vs FFMpegWriter tradeoffs, and memory optimization for long animations.

This article shows how to visualize 3D data and save it as an animated GIF using Matplotlib and NumPy. This technique is especially useful for representing 3D data that changes over time.

Generated GIF

3D animation of sin and cos wave trajectory created with Matplotlib

Code

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from mpl_toolkits.mplot3d import Axes3D # Required for 3D plotting

def make_animation(data_frames, filename="animation.gif"):
    """
    Create and save a 3D data animation as a GIF.

    :param data_frames: List of 3D coordinates (X, Y, Z) for each frame.
                        Example: [[X_frame1, Y_frame1, Z_frame1], [X_frame2, Y_frame2, Z_frame2], ...]
    :param filename: Path to save the GIF file.
    """
    print("Generating frames...")

    fig = plt.figure(figsize=(8, 6)) # Specify figure size
    ax = fig.add_subplot(111, projection='3d')

    # Set axis labels
    ax.set_xlabel('X-axis')
    ax.set_ylabel('Y-axis')
    ax.set_zlabel('Z-axis')

    # Auto-adjust axis range to fit data, or set manually
    # ax.set_xlim(-500, 500) # Example
    # ax.set_ylim(-3, 3)
    # ax.set_zlim(-3, 3)

    ims = [] # List to store Artist objects for each frame

    for i, (X, Y, Z) in enumerate(data_frames):
        # Display progress
        progress = (i + 1) * 100 / len(data_frames)
        print(f"\rProgress: {progress:.1f}%", end="")

        # 3D plot. marker='o' displays points, linestyle='None' hides lines
        im, = ax.plot(X, Y, Z, marker="o", color="red", linestyle='None') # Unpack with comma

        ims.append([im]) # ArtistAnimation expects a list of lists

    print("\nGenerating animation...")

    # Create animation
    # fig: The Figure object for the animation
    # ims: List of Artist objects for each frame
    # interval: Delay between frames (milliseconds)
    # blit: If True, only redraw changed parts for speed (may cause issues with complex 3D plots)
    ani = animation.ArtistAnimation(fig, ims, interval=50, blit=False)

    # Save animation
    # writer: Writer for saving the animation. May require 'ffmpeg' or 'imagemagick'.
    # fps: Frames per second
    ani.save(filename, writer='pillow', fps=20) # 'pillow' can save GIFs without external tools

    print(f"Animation saved to {filename}.")
    plt.show() # Display the animation

def main():
    # Generate sample data
    # 3D trajectory where sin and cos waves change with time t
    t = np.linspace(0, 20 * np.pi, 500) # 500 points from 0 to 20pi

    data_frames = []
    for i in range(len(t)):
        # Example: plot one point per frame
        X_val = t[i] / (20 * np.pi) * 5 # X-axis changes with time
        Y_val = np.sin(t[i])
        Z_val = np.cos(t[i])
        data_frames.append([[X_val], [Y_val], [Z_val]]) # Wrap each value in a list

    make_animation(data_frames, filename="sin_cos_3d_animation.gif")

if __name__ == '__main__':
  main()

Code Explanation

  • matplotlib.animation.ArtistAnimation: Creates an animation by passing a list of Artist objects returned by the plot function, which are drawn for each frame.
  • fig.add_subplot(111, projection='3d'): Required for creating 3D plots.
  • ax.plot(X, Y, Z, ...): Plots data in 3D space. Customize the appearance with marker, color, linestyle, etc.
  • ani.save(filename, writer='pillow', fps=20): Saves the animation as a GIF file.
    • writer='pillow': Uses the Pillow library to generate the GIF. Requires pip install pillow but does not need external tools like ffmpeg.
    • fps: Frames Per Second, controls the animation speed.
  • Progress bar: Uses \r to overwrite the same line, displaying a progress indicator in the console.

Feel free to use this code as a reference to animate and visualize various 3D datasets.

Note that the code above uses ArtistAnimation (pre-build every frame’s Artists up front and pass them as a list). In the rest of this article we’ll use the more flexible FuncAnimation (a function called once per frame that mutates Artist properties in place) to dig into how animation actually works and where the common pitfalls are.

How FuncAnimation Actually Works: The Artist-Update Model and Blitting

Updating Artists in Place, Not Recreating Them

matplotlib.animation.FuncAnimation does not rebuild the whole figure every frame. Instead, it mutates the data of existing Artist objects (Line2D/Line3D, etc.). A typical implementation looks like this:

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.animation as animation

t = np.linspace(0, 8 * np.pi, 150)
x = t / (8 * np.pi) * 5
y = np.sin(t)
z = np.cos(t)

fig = plt.figure(figsize=(6, 4.5))
ax = fig.add_subplot(111, projection="3d")
ax.set_xlim(0, 5)
ax.set_ylim(-1.2, 1.2)
ax.set_zlim(-1.2, 1.2)

(trail,) = ax.plot([], [], [], color="tab:red", lw=1.5)
(head,) = ax.plot([], [], [], color="tab:red", marker="o", linestyle="None")


def update(i):
    # Only mutate the properties of Artists that actually changed
    trail.set_data(x[: i + 1], y[: i + 1])
    trail.set_3d_properties(z[: i + 1])
    head.set_data([x[i]], [y[i]])
    head.set_3d_properties([z[i]])
    # With blit=True, only the Artists returned here are redrawn
    return trail, head


ani = animation.FuncAnimation(fig, update, frames=len(t), interval=40, blit=False)
ani.save("funcanimation_demo.gif", writer="pillow", fps=20)

The key idea is that update(i) replaces only the coordinates of existing Artists via set_data() / set_3d_properties(), and returns the tuple of updated Artists. Because it doesn’t need to pre-generate and hold every frame’s Artists in memory the way ArtistAnimation does, it’s more memory-efficient for long animations and is the more commonly used API.

What Blitting Actually Optimizes

With blit=True, Matplotlib redraws only the bounding-box region of Artists that changed, and reuses the pixels it previously cached via canvas.copy_from_bbox() for everything else (axis labels, gridlines, background, etc.). In other words, blitting optimizes away a full canvas re-rasterization, speeding up interactive on-screen display by skipping redraw work for unchanged regions.

However, there’s an important and easily overlooked fact here. When you call ani.save() to write a file, Matplotlib always draws every frame with blit=False internally, regardless of the blit value you passed. You can verify this directly in the Matplotlib 3.10.x/3.11.x source (Animation.save() in matplotlib/animation.py):

# matplotlib/animation.py (excerpt from Animation.save)
for data in zip(*[a.new_saved_frame_seq() for a in all_anim]):
    for anim, d in zip(all_anim, data):
        # TODO: See if turning off blit is really necessary
        anim._draw_next_frame(d, blit=False)
    writer.grab_frame(**savefig_kwargs)

The comment “TODO: See if turning off blit is really necessary” confirms this is deliberate. The reason is straightforward: writing to a file requires handing the encoder a complete bitmap for every single frame, and blitting’s core assumption — “redraw only the diff and reuse the cached canvas” — doesn’t apply to a save pipeline that isn’t displaying a persistent canvas on screen.

To verify this empirically, we saved a 3D animation that rotates its viewpoint every frame as a GIF with both blit=True and blit=False, then compared the output frames pixel by pixel.

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from PIL import Image, ImageSequence


def make_gif(blit, filename):
    fig = plt.figure(figsize=(4, 4))
    ax = fig.add_subplot(111, projection="3d")
    (line,) = ax.plot([0], [0], [0], marker="o", color="red")

    def update(i):
        line.set_data([np.cos(i / 5)], [np.sin(i / 5)])
        line.set_3d_properties([0])
        ax.view_init(elev=20, azim=i * 10)  # rotate the view every frame
        return (line,)

    ani = animation.FuncAnimation(fig, update, frames=6, blit=blit, interval=200)
    ani.save(filename, writer="pillow", fps=2)
    plt.close(fig)


make_gif(True, "blit_true.gif")
make_gif(False, "blit_false.gif")


def frames_of(path):
    im = Image.open(path)
    return [np.array(f.convert("RGB")) for f in ImageSequence.Iterator(im)]


f_true = frames_of("blit_true.gif")
f_false = frames_of("blit_false.gif")
identical = all(np.array_equal(a, b) for a, b in zip(f_true, f_false))
print(f"All {len(f_true)} frames identical between blit=True and blit=False saves: {identical}")

Output:

All 6 frames identical between blit=True and blit=False saves: True

As expected, the saved GIFs were pixel-for-pixel identical regardless of the blit value. In short, the blit parameter has zero effect when writing to GIF/MP4. Blitting only matters for interactive on-screen display via plt.show() (or a Jupyter widget).

Why Blitting Doesn’t Play Nicely with 3D Axes

Even restricted to interactive display, the combination of 3D Axes (Axes3D) and blitting has a well-known limitation. Matplotlib’s issue tracker has multiple reports that enabling blit=True for a 3D animation breaks mouse-driven rotation and zoom of the view ( matplotlib/matplotlib#8162 ).

The technical reason: blitting composites the screen from “cached background pixels + the diff of changed Artists.” But in a 3D view, changing the camera (elev/azim/roll) requires recomputing the projection and depth ordering (Z-order) of the entire scene. That’s fundamentally different from the 2D case where “only this one Artist’s coordinates changed” — the cached background (panes and gridlines from before the rotation) becomes stale the instant the viewpoint changes. For that reason, interactively rotating a blitted 3D Axes is not officially recommended (use blit=False, or lean on the newer Matplotlib performance improvements discussed later in this article).

That said, as established above, for a workflow like this article’s — saving to GIF/MP4 — the value of blit is effectively moot, since saving always performs a full (blit=False-equivalent) redraw regardless. blit=True only matters when you want fast, real-time interactive display of 2D Axes via plt.show().

Writer/Backend Tradeoffs: PillowWriter (GIF) vs. FFMpegWriter (MP4)

The original code saves the animation as a GIF via writer='pillow', but Matplotlib can also save to MP4 (H.264) via FFMpegWriter. The two have the following tradeoffs:

PillowWriter (GIF)FFMpegWriter (MP4)
External toolingNone (pip install pillow only)Requires the ffmpeg binary
Color depth256-color palette (prone to banding)Full color
Autoplay in a browserAutoplays/loops directly via <img>Requires a <video> element
File size / encode speedWorse, as measured belowBetter, as measured below

The test machine had no system-wide ffmpeg installed (which ffmpeg found nothing), so we installed pip install imageio-ffmpeg, which bundles a static ffmpeg build, and pointed matplotlib.rcParams["animation.ffmpeg_path"] at it. Below is the code we actually ran, and the measured results.

import os
import time
import numpy as np
import matplotlib
import matplotlib.pyplot as plt
import matplotlib.animation as animation

# No system ffmpeg is available, so point Matplotlib at the imageio-ffmpeg binary
import imageio_ffmpeg

matplotlib.rcParams["animation.ffmpeg_path"] = imageio_ffmpeg.get_ffmpeg_exe()


def build_animation(n_frames, dpi):
    t = np.linspace(0, 8 * np.pi, n_frames)
    x, y, z = t / (8 * np.pi) * 5, np.sin(t), np.cos(t)

    fig = plt.figure(figsize=(6, 4.5), dpi=dpi)
    ax = fig.add_subplot(111, projection="3d")
    ax.set_xlim(0, 5)
    ax.set_ylim(-1.2, 1.2)
    ax.set_zlim(-1.2, 1.2)
    (trail,) = ax.plot([], [], [], color="tab:red", lw=1.5)
    (head,) = ax.plot([], [], [], color="tab:red", marker="o", linestyle="None")

    def update(i):
        trail.set_data(x[: i + 1], y[: i + 1])
        trail.set_3d_properties(z[: i + 1])
        head.set_data([x[i]], [y[i]])
        head.set_3d_properties([z[i]])
        return trail, head

    return fig, animation.FuncAnimation(fig, update, frames=n_frames, interval=40)


for n_frames in (60, 150, 300):
    fig, ani = build_animation(n_frames, dpi=100)
    t0 = time.perf_counter()
    ani.save(f"gif_{n_frames}.gif", writer="pillow", fps=20)
    gif_time = time.perf_counter() - t0
    gif_size = os.path.getsize(f"gif_{n_frames}.gif") / 1024
    plt.close(fig)

    fig, ani = build_animation(n_frames, dpi=100)
    t0 = time.perf_counter()
    ani.save(f"mp4_{n_frames}.mp4", writer=animation.FFMpegWriter(fps=20, codec="libx264"))
    mp4_time = time.perf_counter() - t0
    mp4_size = os.path.getsize(f"mp4_{n_frames}.mp4") / 1024
    plt.close(fig)

    print(
        f"frames={n_frames:4d}  GIF: {gif_size:8.1f} KB / {gif_time:5.2f} s   "
        f"MP4: {mp4_size:8.1f} KB / {mp4_time:5.2f} s   "
        f"size ratio(GIF/MP4)={gif_size / mp4_size:5.1f}x"
    )

Measured results (Apple Silicon Mac, Matplotlib 3.11.1, DPI=100 fixed):

frames=  60  GIF:    358.1 KB /  1.64 s   MP4:     31.5 KB /  0.72 s   size ratio(GIF/MP4)= 11.4x
frames= 150  GIF:    637.5 KB /  4.03 s   MP4:     41.3 KB /  1.77 s   size ratio(GIF/MP4)= 15.4x
frames= 300  GIF:   1088.7 KB /  7.97 s   MP4:     74.4 KB /  3.61 s   size ratio(GIF/MP4)= 14.6x

File size and encoding time comparison between PillowWriter (GIF) and FFMpegWriter (MP4)

For the same animation at the same DPI, MP4 came out 11-15x smaller than GIF, and encoded roughly 2x faster. This is because GIF is quantized down to a 256-color palette, while H.264 performs genuine video compression using motion compensation and inter-frame prediction. For quick embedding via an <img> tag with autoplay — like this blog post — GIF’s simplicity still wins. But for animations with many frames or high resolution, it’s worth setting up ffmpeg and writing to MP4 instead.

Memory for Long Animations: Pre-Accumulating All Frames vs. Generating On-the-Fly

The original code accumulates every frame’s Artists into a list (ims) up front because ArtistAnimation requires it. It’s also common to see the underlying data (coordinate arrays) accumulated into a list before the animation loop even starts — but that approach uses memory proportional to the frame count.

FuncAnimation’s update() function, by contrast, can compute only the data needed for the current frame and discard it immediately afterward, which is clearly more memory-efficient for long, high-frame-count animations. We measured peak memory with tracemalloc to confirm this directly.

import gc
import tracemalloc
import numpy as np

N_POINTS = 200  # points per frame


def peak_mb_accumulate_all(n_frames, seed=123):
    """Bad: accumulate every frame's data into a list before animating"""
    tracemalloc.start()
    rng = np.random.default_rng(seed)
    frames = []
    for _ in range(n_frames):
        X = rng.normal(size=N_POINTS)
        Y = rng.normal(size=N_POINTS)
        Z = rng.normal(size=N_POINTS)
        frames.append((X, Y, Z))  # every frame stays resident here
    _, peak = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    del frames
    gc.collect()
    return peak / 1e6


def peak_mb_on_the_fly(n_frames, seed=123):
    """Good: generate each frame inside the update function and discard it"""
    tracemalloc.start()
    rng = np.random.default_rng(seed)
    peak_seen = 0
    for _ in range(n_frames):
        X = rng.normal(size=N_POINTS)
        Y = rng.normal(size=N_POINTS)
        Z = rng.normal(size=N_POINTS)
        _, peak = tracemalloc.get_traced_memory()
        peak_seen = max(peak_seen, peak)
        # X, Y, Z go out of scope and are discarded here
    tracemalloc.stop()
    return peak_seen / 1e6


for n_frames in (500, 1000, 2000, 4000, 8000, 16000):
    list_mb = peak_mb_accumulate_all(n_frames)
    fly_mb = peak_mb_on_the_fly(n_frames)
    print(
        f"n_frames={n_frames:6d}  "
        f"pre-accumulated: {list_mb:8.3f} MB  "
        f"on-the-fly: {fly_mb:8.4f} MB  "
        f"ratio: {list_mb / fly_mb:8.1f}x"
    )

Output:

n_frames=   500  pre-accumulated:    3.476 MB  on-the-fly:   0.0088 MB  ratio:    395.3x
n_frames=  1000  pre-accumulated:    5.218 MB  on-the-fly:   0.0087 MB  ratio:    596.7x
n_frames=  2000  pre-accumulated:   10.433 MB  on-the-fly:   0.0087 MB  ratio:   1193.2x
n_frames=  4000  pre-accumulated:   20.866 MB  on-the-fly:   0.0087 MB  ratio:   2386.3x
n_frames=  8000  pre-accumulated:   41.732 MB  on-the-fly:   0.0087 MB  ratio:   4772.6x
n_frames= 16000  pre-accumulated:   83.465 MB  on-the-fly:   0.0087 MB  ratio:   9545.4x

Peak memory comparison between pre-accumulated frame lists and on-the-fly generation (log scale)

Pre-accumulating into a list grows memory linearly with frame count (each frame’s point cloud is roughly 200 points x 3 axes x 8 bytes ≈ 4.7 KB), reaching about 83 MB at 16,000 frames. Generating on-the-fly, by contrast, only ever holds “the current frame” in memory, staying at roughly 9 KB regardless of frame count. The difference is negligible for a few hundred frames, but for animations spanning tens of thousands of frames or high-resolution data, it can be the difference between a script that runs fine and one that runs out of memory. Prefer computing frame data inside update() whenever the source data allows it.

A Common Gotcha: Forgetting to Keep a Reference to the FuncAnimation Object

FuncAnimation / ArtistAnimation are ordinary Python objects, so like anything else they become eligible for garbage collection once nothing references them anymore. If you build ani = animation.FuncAnimation(...) inside a function and never return ani (i.e., the caller never assigns it to a variable), the object can be destroyed — and the animation can stop mid-run — even while it’s still supposed to be running. This bites people especially often inside a Jupyter Notebook cell wrapped in a function, or when an animation is created transiently inside a GUI callback.

import gc
import weakref
import matplotlib.pyplot as plt
import matplotlib.animation as animation


def make_animation_without_keeping_reference():
    fig, ax = plt.subplots()
    (line,) = ax.plot([], [])

    def update(i):
        line.set_data([0, 1], [0, i])
        return (line,)

    ani = animation.FuncAnimation(fig, update, frames=20, interval=10)
    # Return only a weakref, not ani itself
    # -> once this function returns, nothing holds a strong reference to ani
    return weakref.ref(ani), fig


ani_ref, fig = make_animation_without_keeping_reference()
gc.collect()
print("Still alive after gc.collect() with no reference kept:", ani_ref() is not None)

Output:

Still alive after gc.collect() with no reference kept: False

In fact, running this code triggers Matplotlib’s own built-in warning:

UserWarning: Animation was deleted without rendering anything. This is most likely
not intended. To prevent deletion, assign the Animation to a variable, e.g. `anim`,
that exists until you output the Animation using `plt.show()` or `anim.save()`.

The fix is simple: make sure ani stays referenced by some variable until you call ani.save(...) (or, for interactive use, until the event loop driven by plt.show() has finished running). Assign it to a module-level variable, store it as a class attribute, or simply keep it alive as a local variable in the calling function’s scope — anything that keeps the object alive past the point where it would otherwise be collected.

Recent Developments: Matplotlib’s 3D Performance Improvements and Plotly as an Alternative

Matplotlib’s 3D functionality has kept improving in recent releases.

  • Matplotlib 3.10 (December 2024): mouse-driven 3D rotation became more intuitive (based on Ken Shoemake’s ARCBALL, switchable via rcParams["axes3d.mouserotationstyle"]), and Axes3D.fill_between was added for filling between 3D lines.
  • Matplotlib 3.11 (June 2026): 3D Axes gained support for non-linear scales (log, symlog, logit), a depth-shading fix, and angle snapping while rotating with the Control key held. Most notably, 3D draw performance improved substantially — surface and wireframe plots can render up to 10x faster, thanks to vectorizing how masked array data is handled internally.

These improvements are mostly about the interactive 3D experience rather than changing the GIF/MP4 export workflow covered in this article, but they do matter if you’re animating dense point clouds or high-resolution surfaces, where draw speed becomes the bottleneck.

If your goal is an interactive 3D animation meant to be viewed in a browser, Plotly is also worth considering. It was designed from the ground up for web-based, interactive display, and natively supports frame-based animation along with free mouse rotation and zoom. That said, Matplotlib still has the edge for print-quality static output and for integrating with an existing ax.plot-based scientific computing pipeline like the one in this article — so if your goal is a static animation you plan to distribute as a GIF or MP4, Matplotlib with PillowWriter/FFMpegWriter remains the more convenient choice.