How to export a publication-ready Matplotlib figure

Publication figures need fixed physical dimensions, readable typography, and output formats that match where the graphic will be placed. Matplotlib can produce vector files for manuscript and layout tools while also writing a high-DPI PNG for systems that require raster artwork.

The export path starts with the figure size in inches because Matplotlib uses inches as its native figure unit. A 3.5 inch by 2.4 inch figure saved at 300 DPI becomes a 1050 by 720 pixel PNG, while PDF and SVG keep the same physical page size as vector output.

Set typography, layout, and metadata in the same script that creates the plot so notebook display settings do not become the submission artifact. Inspect the saved files after export, because bbox_inches='tight', different DPI values, or viewer-side scaling can change the file that reaches a journal, slide deck, or documentation page.

Steps to export a publication-ready Matplotlib figure:

  1. Choose the final physical size, raster resolution, and output formats before drawing the plot.
    Figure size: 3.50 x 2.40 in
    Raster DPI: 300
    Vector output: PDF, SVG
    Raster output: PNG

    PDF and SVG stay editable as vector artwork in most layout tools. PNG uses the figure size multiplied by DPI, so a 3.50 inch wide figure at 300 DPI exports as 1050 pixels wide.
    Related: How to set figure size in Matplotlib

  2. Create a script that sets publication typography, uses constrained layout, saves the files, and checks their dimensions.
    publication_figure_export.py
    from pathlib import Path
    import re
    import xml.etree.ElementTree as ET
     
    import matplotlib
    import matplotlib.pyplot as plt
    import numpy as np
    from PIL import Image
     
     
    OUT = Path("exports")
    OUT.mkdir(exist_ok=True)
     
    width_in = 3.50
    height_in = 2.40
    dpi = 300
     
    plt.rcParams.update(
        {
            "font.size": 8,
            "axes.labelsize": 8,
            "axes.titlesize": 9,
            "legend.fontsize": 7,
            "xtick.labelsize": 7,
            "ytick.labelsize": 7,
            "pdf.fonttype": 42,
            "ps.fonttype": 42,
            "svg.fonttype": "none",
        }
    )
     
    days = np.arange(1, 7)
    control = np.array([2.1, 2.4, 2.8, 3.0, 3.4, 3.7])
    treatment = np.array([2.0, 2.7, 3.4, 4.1, 4.6, 5.0])
     
    fig, ax = plt.subplots(figsize=(width_in, height_in), dpi=dpi, layout="constrained")
    ax.plot(days, control, marker="o", linewidth=1.4, label="Control")
    ax.plot(days, treatment, marker="s", linewidth=1.4, label="Treatment")
    ax.set_title("Response over time")
    ax.set_xlabel("Day")
    ax.set_ylabel("Mean response")
    ax.grid(True, linewidth=0.4, alpha=0.35)
    ax.legend(frameon=False)
     
    metadata = {
        "Title": "Publication figure export",
        "Author": "Data Team",
        "Creator": "Matplotlib publication export script",
    }
     
    pdf = OUT / "publication-figure.pdf"
    svg = OUT / "publication-figure.svg"
    png = OUT / "publication-figure.png"
     
    fig.savefig(pdf, metadata=metadata)
    fig.savefig(svg, metadata={"Title": metadata["Title"], "Creator": metadata["Creator"]})
    fig.savefig(png, dpi=dpi, metadata={"Title": metadata["Title"]})
    plt.close(fig)
     
     
    def pdf_size_inches(path):
        match = re.search(
            rb"/MediaBox\s*\[\s*0\s+0\s+([0-9.]+)\s+([0-9.]+)\s*\]",
            path.read_bytes(),
        )
        if not match:
            raise RuntimeError(f"MediaBox not found in {path}")
        return float(match.group(1)) / 72, float(match.group(2)) / 72
     
     
    def svg_unit_to_inches(value):
        if value.endswith("pt"):
            return float(value[:-2]) / 72
        if value.endswith("in"):
            return float(value[:-2])
        raise RuntimeError(f"Unsupported SVG unit: {value}")
     
     
    def svg_size_inches(path):
        root = ET.parse(path).getroot()
        return svg_unit_to_inches(root.attrib["width"]), svg_unit_to_inches(
            root.attrib["height"]
        )
     
     
    def print_inches(label, path, size):
        print(f"{path.name:<24} {label:<3} {size[0]:.2f} x {size[1]:.2f} in")
     
     
    with Image.open(png) as image:
        png_size = image.size
        png_title = image.text.get("Title", "")
     
    print(f"matplotlib {matplotlib.__version__} ({matplotlib.get_backend()} backend)")
    print(f"target                   {width_in:.2f} x {height_in:.2f} in at {dpi} dpi")
    print_inches("PDF", pdf, pdf_size_inches(pdf))
    print_inches("SVG", svg, svg_size_inches(svg))
    print(f"{png.name:<24} PNG {png_size[0]} x {png_size[1]} px")
    print(f"png title metadata      {png_title}")

    layout=“constrained” lets Matplotlib allocate room for labels, ticks, title, and legend before saving. The font settings keep text readable after the figure is placed at its final width.
    Related: How to fix overlapping labels in Matplotlib
    Related: How to configure fonts in Matplotlib

    Use bbox_inches='tight' only when trimmed whitespace matters more than exact page dimensions, because tight bounding can change the saved canvas size.

  3. Run the script and confirm the exported dimensions before submitting the files.
    $ python publication_figure_export.py
    matplotlib 3.11.0 (Agg backend)
    target                   3.50 x 2.40 in at 300 dpi
    publication-figure.pdf   PDF 3.50 x 2.40 in
    publication-figure.svg   SVG 3.50 x 2.40 in
    publication-figure.png   PNG 1050 x 720 px
    png title metadata      Publication figure export

    The PDF and SVG rows should match the target inches. The PNG row should match the target inches multiplied by DPI.