Introduction
Our internal platform generates PDFs in more than twenty places: outgoing invoices and their specifications, travel orders, annual leave decisions, meeting records, performance review reports, training certificates, vehicle cards, equipment handover exports. All of them go through the same library — WeasyPrint, which renders HTML and CSS into a PDF.
For years this was the boring part of the stack. Then, over two weeks in August 2026, it broke twice. First the cover page of one report fell apart. Two weeks later, every PDF on production stopped generating with a TypeError.
Neither failure was caused by our code. Both were caused by how we pinned — and did not pin — our dependencies. This is what happened, and what we changed.
Act one: the pin that was actually a downgrade
On 14 August we tightened requirements.txt and added:
weasyprint==61.0 # PDF rendering must be identical on every host
The intent was right. PDF rendering must be reproducible: the same invoice has to look the same on a developer laptop, on staging and on production. An unpinned renderer means the layout can silently shift under you.
The mistake was in the number. Production had been running a newer WeasyPrint for some time, and everything looked correct. By pinning to 61.0 we did not freeze the current state — we quietly downgraded the renderer on the next deployment.
The bill arrived on the cover page of the performance review report. The title collapsed into a narrow column and overlapped the header with the logo and address. The cause was a column flexbox:
.cover-page {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
WeasyPrint 61 distributes flex-grow incorrectly in a vertical flex container when combined with centering. Version 68 renders the same HTML and CSS correctly. We reproduced it locally on both versions to be sure it was the renderer and not our markup.
The fix was to stop relying on flex for that page: a plain block layout with an absolutely positioned title block (at half the page height, independent of the logo height) and the date pinned near the bottom edge. It now renders identically on 61 and on anything newer.
A detail worth keeping: we also reduced the cover page side margins from 2 cm to 1.5 cm. The title is set in 22 pt and at 2 cm it fit on one line by roughly one percent of the available width. Any small difference in how a version measures text broke it into two lines. A layout that fits "just barely" is not a layout that fits.
Act two: the dependency nobody pinned
On 27 August, PDF generation on a customer's production instance started failing on every single document:
PDF.__init__() takes 1 positional argument but 3 were given
Nothing in our code had changed. weasyprint==61.0 was pinned and installed. The problem was one level down.
WeasyPrint delegates the actual PDF file writing to a small library called pydyf. We had not pinned it — and, crucially, WeasyPrint does not set an upper bound on it either. Its dependency specification accepts any newer version.
So pydyf released 0.11, which changed the signature of PDF.__init__. WeasyPrint 61 still calls it the old way, with two arguments:
pydyf.PDF(version, identifier)
Every call to write_pdf() raised a TypeError. The trigger was nothing more dramatic than a pip install -r requirements.txt on a host that happened to resolve a newer pydyf.
This is the part worth internalising: pinning a package does not pin what that package depends on. A pin gives you a reproducible top-level version and an unpredictable tree underneath it, unless the maintainer is disciplined about upper bounds — and most are not, for good reasons of their own.
Why staging did not warn us
When we went looking, staging had exactly the same breakage. It had been broken for a while. Nobody noticed, because PDFs are rarely generated on staging — people test workflows there, not printouts.
That is a monitoring gap, not bad luck. A dependency failure that only manifests on a code path nobody exercises will always be discovered by a customer first. The lesson is not "test more"; it is "know which code paths your environments never touch", and treat those as untested in production too.
The flexbox trap
This was not our first layout surprise. In May 2026 the outgoing invoice PDF broke on production: the customer block and the meta table overlapped. Locally it was fine — the developer machine ran a newer WeasyPrint where the flexbox implementation behaved.
The fix was to stop using flex and floats for document structure and go back to layout tables:
- invoice header, parties and signature blocks — from flex/float to layout tables
- totals — from
float: righttomargin-left: auto
Tables are CSS 2 core. They have worked identically in every WeasyPrint version we have run. Flexbox is a much younger part of the CSS layout engine and its edge cases genuinely differ between versions.
There is a general rule hiding here. A browser is a moving target you can feature-detect against; a PDF renderer is a fixed target you ship. For print output, boring CSS is a feature. We now treat flexbox in print stylesheets as something that needs justification, not as the default.
How we upgraded 61 to 68.1 without guessing
Having been burned twice, we did not want to swap the renderer and hope. The approach was simple and cheap:
- Render all 18 templates from
templates/on 61.0 and on 68.1 — the same HTML, the same stylesheets, the same data. - Compare page counts. Any difference means content reflowed onto another page, which is the failure mode that hurts most in a document people sign or file.
- Compare the rendered pages pixel by pixel and look at anything above a threshold.
The result: page counts identical on all 18. Pixel differences below 1% on all of them. The two largest — the travel order at 0.82% and the invoice at 0.55% — turned out to be the signature block, which 68 aligns properly and 61 staggers. In other words, the differences were 68 fixing things.
That is a two-hour exercise that turns "we think it's fine" into "we looked at all eighteen". For anything that generates documents a customer receives, it is worth the two hours.
We then pinned the whole rendering stack together, with the reasoning written down where the next person will find it:
weasyprint==68.1
pydyf==0.12.1
tinycss2==1.5.1
cssselect2==0.8.0
pyphen==0.17.2
fonttools==4.61.1
Brotli # fonttools extra — without it WeasyPrint cannot read WOFF2 fonts
zopfli # same
Two notes on that list. Brotli and zopfli have no import anywhere in our codebase — they are optional extras that fonttools picks up at runtime to decompress WOFF2 fonts. A dependency cleanup that removes "unused" packages will happily delete them and your web fonts will silently stop rendering. They are commented for exactly that reason.
Also: WeasyPrint 68 requires Python 3.10 or newer. Our servers run 3.12, so this was free — but on an older host that single line turns a library upgrade into a platform upgrade.
What we changed in how we work
- Pin the whole rendering stack, not just the renderer. WeasyPrint, pydyf, tinycss2, cssselect2, pyphen, fonttools move as one unit and are upgraded as one unit.
- Know what production actually runs before you pin. A pin is a decision about which version, not a decision to freeze whatever is there. Check the running version first, or the pin becomes an unplanned downgrade.
- Write the reason next to the pin. A bare version number invites someone to bump it. A line explaining which bug it prevents does not.
- Verify rendering, not imports. "The application starts" says nothing about PDFs. Render every template and compare page counts before and after.
- Prefer CSS 2 for print. Tables and absolute positioning behave the same across versions. Flex and grid do not, yet.
- Do not let a layout fit by one percent. If a title needs 99% of the line, the next version will break it.
Conclusion
None of this was exotic. A pinned library with an unpinned dependency, a version number chosen without checking what was running, and a CSS feature that is less portable than it looks. Each one is the kind of thing that passes code review easily.
What made it expensive was that PDFs are the output customers actually keep — the invoice, the decision, the certificate. When rendering breaks, it breaks quietly at the last step of a workflow that otherwise appears to succeed. That is worth a little more paranoia than the rest of the dependency tree gets.
