SVG Generation in Python — Deep Dive

Choosing your approach

The right tool depends on your use case:

ApproachBest forTradeoff
svgwriteStructured document buildingModerate API, no rasterization
drawsvgQuick sketches, animationsSmaller community
Jinja2 templatesHTML/SVG integration, webNo shape validation
xml.etreeZero dependenciesVerbose, manual escaping
Cairo → SVGComplex 2D renderingHeavier dependency

For most production use cases, svgwrite or Jinja2 templates are the pragmatic choices.

Advanced svgwrite patterns

Reusable symbols

import svgwrite

dwg = svgwrite.Drawing("dashboard.svg", size=("1200px", "800px"))

# Define a reusable icon
icon = dwg.symbol(id="alert-icon")
icon.add(dwg.circle(center=(12, 12), r=10, fill="none", stroke="#e74c3c", stroke_width=2))
icon.add(dwg.text("!", insert=(9, 17), fill="#e74c3c", font_size="16px", font_weight="bold"))
dwg.defs.add(icon)

# Stamp it at multiple positions
for x, y in [(100, 50), (300, 150), (500, 250)]:
    dwg.add(dwg.use(icon, insert=(x, y), size=(24, 24)))

dwg.save()

Symbols defined in <defs> are not rendered until referenced by <use>, keeping the file efficient when the same graphic appears many times.

Gradients and filters

# Linear gradient
grad = dwg.linearGradient(id="sky", start=("0%", "0%"), end=("0%", "100%"))
grad.add_stop_color(offset="0%", color="#1a1a2e")
grad.add_stop_color(offset="100%", color="#16213e")
dwg.defs.add(grad)

dwg.add(dwg.rect(size=("100%", "100%"), fill="url(#sky)"))

# Drop shadow filter
shadow = dwg.defs.add(dwg.filter(id="shadow", x="-20%", y="-20%", width="140%", height="140%"))
shadow.feGaussianBlur(in_="SourceAlpha", stdDeviation=4, result="blur")
shadow.feOffset(in_="blur", dx=2, dy=3, result="shifted")
merge = shadow.feMerge()
merge.feMergeNode(in_="shifted")
merge.feMergeNode(in_="SourceGraphic")

Complex paths with the path data mini-language

# Draw a smooth Bézier curve chart
data_points = [(0, 280), (100, 200), (200, 240), (300, 120), (400, 160), (500, 60)]

d = f"M {data_points[0][0]},{data_points[0][1]}"
for i in range(1, len(data_points)):
    x0, y0 = data_points[i - 1]
    x1, y1 = data_points[i]
    cx = (x0 + x1) / 2
    d += f" C {cx},{y0} {cx},{y1} {x1},{y1}"

dwg.add(dwg.path(d=d, fill="none", stroke="#3498db", stroke_width=3))

Cubic Bézier curves (C) create smooth connections between data points without the jaggedness of straight lines.

Generating SVGs with Jinja2

For web applications, Jinja2 templates offer clean separation of structure and data:

{# chart.svg.j2 #}
<svg viewBox="0 0 {{ width }} {{ height }}" xmlns="http://www.w3.org/2000/svg">
  <style>
    .bar { fill: #3498db; }
    .bar:hover { fill: #2980b9; }
    .label { font: 12px Inter, sans-serif; fill: #333; }
  </style>
  {% for item in data %}
  <rect class="bar"
        x="{{ item.x }}" y="{{ item.y }}"
        width="{{ bar_width }}" height="{{ item.height }}"
        rx="4">
    <title>{{ item.label }}: {{ item.value }}</title>
  </rect>
  <text class="label" x="{{ item.x + bar_width / 2 }}"
        y="{{ height - 5 }}" text-anchor="middle">
    {{ item.label }}
  </text>
  {% endfor %}
</svg>
from jinja2 import Environment, FileSystemLoader

env = Environment(loader=FileSystemLoader("templates"))
template = env.get_template("chart.svg.j2")

svg_content = template.render(
    width=600, height=400, bar_width=40,
    data=[
        {"label": "Q1", "value": 120, "x": 50, "y": 100, "height": 240},
        {"label": "Q2", "value": 85, "x": 110, "y": 170, "height": 170},
        # ...
    ]
)

with open("chart.svg", "w") as f:
    f.write(svg_content)

This approach is especially powerful when SVGs are served dynamically from a web framework like Flask or FastAPI.

SVG animation

CSS-based animation (embedded in SVG)

style = dwg.style("""
    @keyframes pulse {
        0% { opacity: 0.3; }
        50% { opacity: 1; }
        100% { opacity: 0.3; }
    }
    .blinking { animation: pulse 2s ease-in-out infinite; }
""")
dwg.defs.add(style)
dwg.add(dwg.circle(center=(200, 200), r=30, fill="red", class_="blinking"))

SMIL animation (native SVG)

circle = dwg.circle(center=(100, 200), r=20, fill="#2ecc71")
circle.add(dwg.animate(
    attributeName="cx", from_="100", to="500",
    dur="3s", repeatCount="indefinite"
))
dwg.add(circle)

CSS animations are more widely supported in modern browsers than SMIL, though SMIL offers more SVG-specific control (animating path data, for example).

Accessibility

Generated SVGs should be accessible:

dwg.add(dwg.title("Monthly Sales Chart"))
dwg.add(dwg.desc("Bar chart showing sales figures from January to June 2026"))

# Use role="img" and aria-label on the root SVG when embedding in HTML

For data visualizations, add <title> elements to interactive shapes so screen readers can describe them.

Optimization

File size reduction

Production SVGs can be bloated. Optimization strategies:

  • Round coordinates123.456789123.5 saves bytes across thousands of elements
  • Minimize path precision — Most screens cannot distinguish sub-pixel differences
  • Remove metadata — Editor-injected comments and namespaces add noise
  • Use <symbol> and <use> — Deduplicate repeated elements

The scour Python package automates these optimizations:

import subprocess
subprocess.run(["scour", "-i", "input.svg", "-o", "output.svg",
                "--enable-id-stripping", "--shorten-ids",
                "--remove-metadata", "--indent=none"])

For JavaScript-based pipelines, SVGO is the industry standard.

Server-side rasterization

When you need PNG/PDF output from generated SVGs:

import cairosvg

cairosvg.svg2png(url="chart.svg", write_to="chart.png", dpi=300)
cairosvg.svg2pdf(url="chart.svg", write_to="chart.pdf")

CairoSVG handles most SVG features but may not support advanced CSS or SMIL animations.

Testing generated SVGs

from lxml import etree

def validate_svg(path):
    tree = etree.parse(path)
    root = tree.getroot()

    assert root.tag.endswith("svg"), "Root is not <svg>"
    assert root.get("viewBox") is not None, "Missing viewBox"

    # Check all text elements have content
    for text in root.iter("{http://www.w3.org/2000/svg}text"):
        assert text.text and text.text.strip(), f"Empty text at {text.get('x')},{text.get('y')}"

    # Check no zero-dimension shapes
    for rect in root.iter("{http://www.w3.org/2000/svg}rect"):
        w = float(rect.get("width", 0))
        h = float(rect.get("height", 0))
        assert w > 0 and h > 0, f"Zero-dimension rect"

For visual regression testing, render SVGs to PNG and compare against baselines using pixelmatch or pytest-image-diff.

Real-world architecture

A production SVG generation service typically follows this flow:

  1. Data layer — Query database for chart data, user info, or configuration
  2. Layout engine — Calculate positions, sizes, and scales in Python
  3. SVG builder — Construct the SVG using svgwrite or templates
  4. Post-processing — Optimize with scour, optionally rasterize with CairoSVG
  5. Delivery — Serve inline in HTML, as a standalone file, or as a cached static asset

For high-throughput services (thousands of SVGs per minute), pre-compute layouts and cache templates. SVG generation itself is CPU-bound string manipulation — fast but parallelizable across workers.

One thing to remember

SVG generation in Python combines the precision of vector graphics with the automation power of code — enabling data-driven, accessible, and resolution-independent visual output that integrates seamlessly with web platforms and print pipelines.

pythonsvgvector-graphicsweb

See Also

  • Python Adaptive Learning Systems How Python builds learning apps that adjust to each student like a personal tutor who knows exactly what you need next.
  • Python Airflow Learn Airflow as a timetable manager that makes sure data tasks run in the right order every day.
  • Python Altair Learn Altair through the idea of drawing charts by describing rules, not by hand-placing every visual element.
  • Python Automated Grading How Python grades homework and exams automatically, from simple answer keys to understanding written essays.
  • Python Batch Vs Stream Processing Batch processing is like doing laundry once a week; stream processing is like a self-cleaning shirt that cleans itself constantly.