Skip to content
  • Document automation
  • Python
  • Engineering

Contract generation with Python, Jinja2 and WeasyPrint, a technical guide

A technical guide to contract generation with Python, Jinja2 and WeasyPrint, with Word output, clause libraries, snapshot tests, versioning and e-signature.

Agon AliuSilicode8 min read

You can build a reliable contract generation engine in Python with three well-established open-source pieces: Jinja2 for templates and clause logic, WeasyPrint to turn HTML and CSS into a print-quality PDF, and docxtpl (built on python-docx) when reviewers need an editable Word file. The hard part is not the libraries. It is structuring clauses, rules, tests and versions so lawyers can trust every document the engine produces.

This guide walks through that structure with short code samples. It reflects the approach behind the engine we built for a law firm in Kosovo, simplified so you can adapt it to your own documents.

Architecture in one picture

A contract engine has four layers, and keeping them separate is what makes it maintainable.

  1. Input: structured data from a CRM, form or database, validated before anything is rendered.
  2. Rules: plain Python that decides which clauses and variants apply.
  3. Templates: Jinja2 templates that handle wording and layout only.
  4. Output: HTML rendered to PDF with WeasyPrint, and optionally a Word file, plus an audit record.

The most common mistake we see is pushing business rules into templates until nobody can read them. Simple conditions in templates are fine. Rules such as "which governing law applies to this client in this situation" belong in tested Python code.

Templates and a clause library with Jinja2

Set up the Jinja2 environment once. Two settings matter for contracts: autoescaping, so a party name containing < or & cannot break the HTML, and StrictUndefined, so a missing field raises an error instead of silently rendering an empty string.

from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape

env = Environment(
    loader=FileSystemLoader("templates"),
    autoescape=select_autoescape(["html", "j2"]),
    undefined=StrictUndefined,
    trim_blocks=True,
    lstrip_blocks=True,
)
env.filters["long_date"] = lambda d: d.strftime("%d %B %Y")

A contract template extends a shared base layout and pulls clauses from a library. Each clause is its own file, with its version in the file name:

templates/
  base.html.j2
  nda.html.j2
  clauses/
    confidentiality_v3.html.j2
    term_v2.html.j2
    mutual_obligations_v1.html.j2
    governing_law_kosovo_v2.html.j2
    governing_law_default_v4.html.j2

The contract template stays short:

{% extends "base.html.j2" %}
{% block body %}
<h1>Non-Disclosure Agreement</h1>
<p>This agreement is made on {{ contract.effective_date | long_date }}
between {{ party_a.name }} and {{ party_b.name }}.</p>

<ol class="clauses">
{% for clause in clauses %}
  <li>{% include "clauses/" ~ clause.id ~ "_v" ~ clause.version ~ ".html.j2" %}</li>
{% endfor %}
</ol>
{% endblock %}

Using an ordered list for clauses means numbering stays correct when an optional clause drops out. If clauses refer to each other by number, generate those references from the clause list rather than typing them into the wording.

Conditional logic in Python, not in templates

The rules that decide which clauses apply live in one function with no template code in it. Lawyers can read it, and it is easy to unit test.

from dataclasses import dataclass
from datetime import date

@dataclass(frozen=True)
class Clause:
    id: str
    version: int

def select_clauses(contract: dict) -> list[Clause]:
    clauses = [Clause("confidentiality", 3), Clause("term", 2)]
    if contract["mutual"]:
        clauses.append(Clause("mutual_obligations", 1))
    if contract["jurisdiction"] == "XK":
        clauses.append(Clause("governing_law_kosovo", 2))
    else:
        clauses.append(Clause("governing_law_default", 4))
    return clauses

def build_context(data: dict) -> dict:
    contract = {
        **data["contract"],
        "effective_date": date.fromisoformat(data["contract"]["effective_date"]),
    }
    return {**data, "contract": contract, "clauses": select_clauses(contract)}

def render_contract(template_name: str, context: dict) -> str:
    return env.get_template(template_name).render(**context)

Before calling build_context, validate the input against a schema (for example with pydantic or a JSON Schema validator) so that a missing jurisdiction or a malformed date is rejected with a clear message. An engine should refuse to produce a contract rather than produce a wrong one.

PDF output with WeasyPrint

WeasyPrint renders HTML and CSS to PDF and supports CSS paged media, which is what makes it suitable for contracts: page size, margins, page numbers and running headers are all plain CSS.

from weasyprint import HTML

def to_pdf(html: str, out_path: str) -> None:
    HTML(string=html, base_url="templates").write_pdf(out_path)

base_url tells WeasyPrint where to resolve relative paths to stylesheets, fonts and logos. The print styles live in the base template's stylesheet:

@page {
  size: A4;
  margin: 25mm 20mm;
  @bottom-right {
    content: "Page " counter(page) " of " counter(pages);
    font-size: 9pt;
  }
}
.clauses li { break-inside: avoid; }
h1, h2 { break-after: avoid; }

Embed the firm's fonts with @font-face instead of relying on fonts installed on the server, so the output looks the same on every machine and in every environment. See the WeasyPrint API reference for the full set of options.

Producing an editable Word file

Reviewers often want a .docx they can mark up. There are two practical routes.

Route 1: a Word template with docxtpl

docxtpl lets you put Jinja2 tags directly into a Word document and render it from Python. It uses python-docx for the Word side and Jinja2 for the tags.

from docxtpl import DocxTemplate

def to_docx(context: dict, out_path: str) -> None:
    doc = DocxTemplate("templates/nda.docx")
    doc.render(context)
    doc.save(out_path)

Inside the Word template you write {{ party_a.name }} for fields, and {%p if contract.mutual %} ... {%p endif %} on their own paragraphs to include or remove whole paragraphs. The paragraphs holding those tags are removed from the output.

The cost of this route is two templates per contract, HTML and Word, which can drift apart. Cover both with the same test cases (see below) and change them in the same commit.

Route 2: Word as the single source

If the Word version is the master, generate only the .docx with docxtpl and convert it to PDF with LibreOffice in headless mode (soffice --headless --convert-to pdf). You get one template per contract, at the price of a heavier runtime dependency and less precise control over PDF layout than CSS gives you.

We generally choose route 1 when the PDF must be pixel-consistent and high volume, and route 2 when legal staff maintain the templates themselves in Word.

Testing templates with snapshot tests

Templates are code and should be tested like code. Three kinds of tests cover most risk.

  • Rule tests: plain unit tests for select_clauses, one per rule and edge case.
  • Missing data tests: render with a field removed and assert that StrictUndefined raises jinja2.exceptions.UndefinedError.
  • Snapshot tests: render a set of realistic test cases and compare the output with an approved copy.

With pytest and the syrupy snapshot plugin, a snapshot test is a few lines:

import json
from pathlib import Path
import pytest
from contracts import build_context, render_contract

CASES = sorted(p.stem for p in Path("tests/cases").glob("*.json"))

@pytest.mark.parametrize("case", CASES)
def test_contract_html_matches_snapshot(case, snapshot):
    data = json.loads(Path(f"tests/cases/{case}.json").read_text())
    html = render_contract("nda.html.j2", build_context(data))
    assert html == snapshot

The test cases store dates as ISO strings, and build_context parses them into date objects, so the long_date filter receives a real date. Snapshot the HTML rather than the PDF bytes: PDF files are binary and can contain metadata such as creation dates, so they are poor material for diffs. Add a smoke test that each case still renders to a PDF without errors.

When a template change is intended, run pytest --snapshot-update, and the diff of the snapshot files becomes part of the code review. A lawyer can read that diff and approve the exact wording change before it reaches production.

Versioning and traceability

For every generated contract you should be able to answer: which template, which clause versions, which rules and which input produced it. A simple way to get there:

  • Keep templates, clauses and rules in Git, and release them together with a version tag.
  • Never edit a published clause file. Create a new version and update the rule that selects it.
  • Store an audit record per document: template release, clause list with versions, a hash of the input data, a hash of the output file, the user or system that triggered it and a timestamp.
import hashlib
import json
from pathlib import Path

# data, context and pdf_path come from the generation run; RELEASE_TAG from your build
def sha256(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()

audit = {
    "template_release": RELEASE_TAG,
    "clauses": [f"{c.id}_v{c.version}" for c in context["clauses"]],
    "input_sha256": sha256(json.dumps(data, sort_keys=True, default=str).encode()),
    "pdf_sha256": sha256(Path(pdf_path).read_bytes()),
}

With that record you can reproduce or explain any document months later, which is what auditors and opposing counsel will ask for.

Handing off to e-signature

Most e-signature providers expose a REST API and webhooks, for example the Docusign eSignature REST API with its Connect webhooks. The flow is the same whichever provider you use:

  1. Upload the generated PDF and create a signing request with the signers, their order and the signature positions. Placing invisible anchor text in the template, which the provider uses to position signature fields, avoids hard-coded page coordinates.
  2. Store the provider's request ID next to your audit record.
  3. Receive status updates through a webhook (sent, viewed, signed, declined). Verify the webhook's signature or shared secret before trusting it, and make the handler idempotent because deliveries can repeat.
  4. When signing completes, download the signed PDF and the provider's audit trail, and store both with the original.

Check which signature level your documents need. The EU eIDAS Regulation distinguishes simple, advanced and qualified electronic signatures, and only qualified signatures have the legal effect of a handwritten signature in every member state. Which level each document type needs is a legal decision, so agree it with your lawyers before you build the hand-off.

Build checklist

  1. Input validated against a schema before rendering.
  2. Rules in tested Python, templates for wording only.
  3. Autoescaping and StrictUndefined switched on.
  4. Clause files versioned and never edited after release.
  5. Print CSS with page numbers, break rules and embedded fonts.
  6. Snapshot tests reviewed by the content owner on every change.
  7. An audit record per document with template release and hashes.
  8. Idempotent, verified webhook handling for e-signature.

Frequently asked questions

Why WeasyPrint instead of generating PDFs directly?

Building contracts in HTML and CSS lets you use a templating language and styling tools that many developers already know, and WeasyPrint's paged media support handles page numbers, margins and page breaks. Low-level PDF libraries give more control but make long, flowing legal text much harder to lay out and maintain.

Can non-developers edit the templates?

With route 2, legal staff can edit Word templates directly, guarded by snapshot tests. With HTML templates, we recommend an internal tool where authorised staff edit clause wording and preview the result, while developers own the rules and layout.

How do we stop a template change breaking existing contracts?

Version clauses instead of editing them, release templates together, and require snapshot diffs to be approved before merging. Each generated contract records the release it was built from, so older documents remain explainable.

Is Python fast enough for high volume?

For typical contracts, yes. Rendering is usually fast, and PDF generation can be run in parallel workers or serverless functions. The law firm engine in our case study processes more than 10,000 documents a month.


This is the technical side of the story told in how we cut contract drafting from 45 minutes to 90 seconds. If you are weighing the budget first, read what drives document automation cost. To discuss your own templates, see our document automation service, book a call or request a quote.

Tell us what you need to build

A 30-minute call is enough to understand your goals and tell you honestly how we would approach it, and whether we are the right fit.