Skip to main content

Inlining CSS in a Python Pipeline With Premailer

Wire an inliner into a Python send path: keeping media queries, preserving MSO conditionals, caching the parse, and asserting the output before dispatch.

A Python send path renders a template, inlines its CSS, and hands the result to an ESP — and the inline step is where responsive behaviour, Outlook conditionals and throughput all quietly go wrong. This guide covers configuring the step correctly and asserting its output before dispatch.

Root Cause: The Inliner Sees HTML, Not Your Intent

An inliner parses the document, matches every selector against it, and writes the resulting declarations onto elements as style attributes. It has no model of what the markup is for, so anything whose meaning depends on context is at risk.

Three things are commonly lost. Media queries cannot be expressed on an element, so an inliner that removes the style block after inlining takes the responsive layer with it. MSO conditional comments are comments, and a parser configured to strip comments removes the entire Outlook code path — leaving a template that renders correctly everywhere except the client the conditionals existed for. And element order can shift when a parser normalises the document, which matters for VML blocks whose position relative to their content is significant.

None of these produce an error. The inliner completes successfully, the output is valid HTML, and the failure appears only in the client that needed the removed thing.

What survives an inline pass Element-scoped declarations move to style attributes, while media queries, conditional comments and comment-wrapped VML are removed unless preserved explicitly. The Inliner Completes Successfully Either Way moved to style attributes colour, font, padding, width borders and backgrounds Everything expressible on one element, resolved by specificity and order. removed unless preserved @media — the responsive layer MSO conditionals — the Outlook path No error is raised. The output is valid HTML that is missing a whole client.
Both columns are the same successful run — the difference is entirely in configuration, and neither produces a warning.

The Exact Configuration

# inliner.py — a configured inline step for a transactional send path.
from premailer import Premailer


def build_inliner() -> Premailer:
    return Premailer(
        # Keep the <style> block after inlining. Without this the media
        # queries go with it and the layout collapses to desktop on mobile.
        remove_classes=False,
        keep_style_tags=True,
        # Do not touch anything inside comments: MSO conditionals and the VML
        # they wrap are comment-delimited, and stripping them removes the
        # entire Outlook 2016-2021 rendering path.
        strip_important=False,
        # Resolve relative URLs against the CDN so a client that rewrites the
        # document cannot break asset paths.
        base_url="https://cdn.example.com/",
        # Never fetch remote stylesheets during a send: a network call in the
        # dispatch path is a latency and availability risk for no benefit.
        exclude_pseudoclasses=True,
        disable_validation=True,
        cssutils_logging_level="CRITICAL",
    )


_INLINER = build_inliner()   # module-level: parsing config is not free


def inline(html: str) -> str:
    return _INLINER.transform(html)

keep_style_tags=True is the single most important setting and the one most often left at its default. The default behaviour — inline and discard — is correct for a document with no responsive layer and wrong for every modern email template.

Constructing the inliner once at module scope rather than per message matters at volume. The constructor parses configuration and initialises the CSS parser, and doing that per send adds measurable latency to every message in a loop that may run thousands of times a minute.

Where the Step Belongs in the Pipeline

Order of operations decides correctness, not just performance.

The inline step must run after template rendering, so the CSS is applied to the final document including any conditionally rendered blocks. Inlining a template before data binding means rows added by a loop never receive their styles — a defect that appears only when the collection is non-empty, which is why it survives testing with a single-item fixture.

It must run before tracking rewrites, because a tracking pass that rewrites href values on an already-inlined document is safe, whereas inlining a document whose links have already been rewritten can produce selector mismatches where a rule targeted an attribute the rewrite changed.

And it should run once per template version rather than once per recipient wherever the template's structure does not vary by recipient. The inline pass is the most expensive step in a typical render path, and caching its output keyed by template version turns a per-message cost into a per-deploy one.

from functools import lru_cache

@lru_cache(maxsize=256)
def inline_skeleton(template_id: str, version: str) -> str:
    """Inline the structure once; bind recipient data into the result.

    Only valid when the template's ELEMENTS do not vary by recipient — a
    conditional block that adds a styled section per recipient must be
    inlined per message, or that section ships unstyled.
    """
    return inline(render_skeleton(template_id))
Ordering of the inline step Render first so conditional blocks exist, inline second, then apply tracking rewrites before dispatch. Render, Inline, Track — In That Order 1 — render loops expanded, so every row exists 2 — inline style block kept for the media queries 3 — tracking hrefs rewritten after selectors matched 4 — assert then dispatch Inlining before the render leaves loop-generated rows unstyled — invisible with a single-item fixture.
The ordering bug is invisible in testing precisely because a one-item fixture produces no loop-generated rows to leave unstyled.

Asserting the Output Before Dispatch

The inline step fails silently, so the send path should assert its output rather than trust it. Four cheap checks catch essentially every configuration regression.

import re

MEDIA_RE = re.compile(r"@media[^{]*\{", re.I)
MSO_RE = re.compile(r"<!--\[if\s+(gte\s+)?mso", re.I)


def assert_inlined(html: str) -> None:
    """Raise before dispatch rather than discovering the loss in an inbox."""
    # 1. The responsive layer survived.
    if not MEDIA_RE.search(html):
        raise ValueError("inline step removed the media queries")

    # 2. The Outlook path survived.
    if not MSO_RE.search(html):
        raise ValueError("inline step removed the MSO conditionals")

    # 3. Styles actually reached the elements — a run that silently did
    #    nothing produces a document with a style block and no attributes.
    if html.count('style="') < 20:
        raise ValueError("suspiciously few inline style attributes")

    # 4. No unresolved template syntax leaked through the render.
    if "{{" in html or "{%" in html:
        raise ValueError("unrendered template syntax in the output")

The third check is the one that catches the subtle case. A misconfiguration where the inliner runs but matches nothing — a selector prefix change, a document the parser could not read — produces valid output with the style block intact and no inline attributes at all, which passes the first two checks and renders as an unstyled message in every client that strips the block.

Throughput at Send-Time Volume

The inline step is usually the slowest part of a render path, and at volume its cost dominates everything else. Three measurements are worth taking before deciding whether it matters for you.

Time per message. A typical transactional template takes between 20 and 120 milliseconds to inline in pure Python, depending on document size and selector count. At ten messages a second that is invisible; at five hundred it is the constraint, and it will show up as worker saturation rather than as an obvious inlining problem.

Selector count against element count. The work is roughly proportional to their product, because every selector is matched against every candidate element. A stylesheet that has accumulated rules for components a given template does not use pays for all of them, which is why purging unused CSS before inlining frequently halves the time as well as the output size.

Cache hit rate on the skeleton. Where the skeleton cache described above applies, the effective per-message cost falls to the data-binding step alone. A low hit rate usually means the cache key includes something recipient-specific by accident — a rendered timestamp, a personalised subject folded into the key — which silently disables it.

The order to attack these in is cache, then purge, then implementation. Moving to a Rust-backed inliner is the largest single win available and also the most disruptive, so it is worth confirming the first two are in place before taking on a dependency change: a pipeline with a working skeleton cache often does not need it at all.

Reducing per-message inline cost Skeleton caching removes most of the per-message cost, purging unused CSS reduces the remainder, and a faster implementation reduces it further. Cache First, Purge Second, Rewrite Last baseline 86 ms + skeleton cache 21 ms — the biggest single win + purge unused CSS 13 ms — also reduces message size A pipeline with a working skeleton cache often never needs the faster implementation at all.
The cheapest optimisation is the one that avoids the work entirely, which is why the cache comes before any change of library.

Validation and Deployment Checklist

Frequently Asked Questions

Should we use premailer or css_inline?

css_inline is a Rust extension and is substantially faster, which matters in a high-volume send path; premailer is pure Python and has been around longer, with correspondingly more accumulated behaviour. Both support keeping the style block. The choice is usually decided by throughput: below a few hundred messages a minute the difference is irrelevant, and above it the Rust implementation is worth the dependency.

Can inlining happen at build time rather than at send time?

For the template skeleton, yes, and it is the single biggest performance win available — the structure is inlined once per deploy and only the data is bound per message. It stops working when the elements vary per recipient rather than only their content, since a conditionally added section would ship unstyled.

Why does our output have duplicate declarations on some elements?

Because a property matched by several rules is written once per rule, in specificity order. It is harmless — the last one wins, as in any cascade — but it inflates the message size. Narrowing broad selectors reduces both the duplication and the total weight.

Does the inliner handle !important correctly?

It preserves the flag when writing the declaration, which is what you want for the dark-mode and mobile overrides that rely on it. What it cannot do is resolve an !important in a media query against an inline declaration, because that resolution happens in the client — which is exactly why those overrides need the flag in the first place.


← Back to Inline CSS Automation