No Rust, No Release Branch: The Road to EdgarTools 6.0

EdgarTools 6.0 lands November 9. Three decisions shaped it: no Rust rewrite, no release branch, and every breaking change warns from inside 5.x first. Here is what breaks, and what each break buys.

Continuous 5.x releases, a three-week freeze window from October 12, then EdgarTools 6.0 on November 9, 2026

Python 3.10 reaches end of life on October 31, 2026. EdgarTools 6.0 ships nine days later, on November 9, and that ordering is deliberate — the date was chosen because three external facts agree on that week, which makes it the rare engineering deadline that argues for itself.

The 5.x line is forty-six minor versions deep, and this is the first major release since EdgarTools 5 — a tradition of writing the plan down in public that goes back to the Release 4 roadmap. A major version is the one window where a library is allowed to break things, so this post is the contract for how that window gets used: what breaks, what each break buys you, and the three decisions that shaped the release — there will be no Rust rewrite, there will be no release branch, and every break that can warn you from inside 5.x will have done so before 6.0 exists. The full plan is in the repo; this is the version worth reading.

The calendar picked the date

Dropping a Python version is normally an argument a maintainer has to win against their own users. This time the argument makes itself, three ways.

pandas 3.0 shipped on January 21 and declares requires-python >=3.11, and 6.0 adopts pandas 3.0 — so there is no version of this release that takes the new pandas and keeps Python 3.10. What looks like two breaking changes is one decision.

Python 3.10 itself dies on October 31. Releasing after that date turns the floor bump into a statement of fact — 3.10 is end-of-life, 6.0 requires 3.11 — where releasing before it would mean pushing users off a runtime that upstream still supports.

And the tail being cut is small: 3.10 accounts for 3.8% of EdgarTools downloads over the last 180 days, less than the 8.9% already running Python 3.14. Under SPEC 0, the scientific-Python convention of dropping a Python roughly three years after its release, 3.10 was due to go in October 2024 — this release is late, not early. Even the 3.8% is best read as a ceiling, because download counts include CI runners and mirrors, and the real users inside that number skew toward locked-down enterprise environments — exactly the cohort whose own IT policy will move them once the runtime goes end-of-life.

There was no case for going later, either: Python 3.11 isn't end-of-life until October 2027, so slipping past November reaches no further natural boundary. Hence November 9. If you maintain a library and dread the "why did you drop my Python" issue, this move is stealable — anchor the bump to clocks you don't control, and time the release so the calendar has already made your argument.

The rewrite we didn't do

The fashionable 6.0 would be a Rust rewrite. The question got a formal evaluation in late July and a short answer: no.

Three reasons, in increasing order of importance. A pure-Python wheel that installs anywhere, PyPy included, is a feature nobody notices until it's gone. The heavy lifting is already native — HTML parsing runs on lxml, tables on pyarrow, JSON on orjson, fuzzy matching on rapidfuzz — so the compiled layer a rewrite would add mostly exists. And the hottest pure-Python code is the worst possible candidate for compilation: the table heuristics and TOC analysis that decide whether a row of an SEC filing is a header or data changed in thirteen pull requests over one recent nine-day stretch, and a heuristic that moves weekly cannot live in a compiled extension without freezing exactly the code that most needs to stay soft.

What replaces the rewrite is a rule: no optimization merges without a before/after number from a profiling baseline. The rule is new for 6.0, but the evidence for it is already on the books, because profiling keeps finding speed in embarrassing places:

Four profiling finds: section extraction 4.4 times faster, Filing.text() 4.5 times, a giant-table pass 179 times, 13F parsing 8 to 20 times — each from removing redundant work, not from a faster language
  • Section extraction ran a full-document XPath query per anchor lookup — Morgan Stanley's 9.8 MB 10-K made 92 of them, two-thirds re-resolving an id already resolved. One indexing pass now serves every lookup: 4.4× faster across the benchmark corpus, output unchanged.
  • The same stage md5-hashed the entire filing once per section to rebuild a cache key that could not have changed — 430.7 MB hashed to answer a question about a 9.8 MB document.
  • Filing.text() rendered a 400 KB string through a terminal-emulation library and then stripped the ANSI codes back out, to recover text the extractor had already produced. Calling the extractor directly is 4.5× faster.
  • A dimension pass on very large tables rescanned an empty grid once per preceding row, which turned one Fannie Mae filing (0000310522-18-000010) into a 1-hour-12-minute parse. Fixed, it takes 24.1 seconds — a factor of 179, byte-identical output.

Not one of those is "Python is slow." Each one is redundant work, and a Rust rewrite would have ported the redundancy faithfully and executed it very fast. If your own hot path feels like it needs a rewrite, profile it first — the big factors hide in accidental rescans and redundant hashing, and a rewrite carries them across lovingly.

The one place 6.0 does swap an engine is BeautifulSoup: 32 files migrate to lxml, a project anchored to a measured 8–20× speedup on 13F holdings parsing, with 5–15× expected on the other XML paths. Measured before, measured after, like everything else.

Freeze releases, not commits

There is no 6.0 branch. Everything lands on main, which stays the only branch, and the thing that freezes for the breaking window is the release stream.

The repository's own throughput is the argument. In the nine days after the 6.0 plan opened, 38 pull requests merged, thirteen of them in section extraction — the very subsystem 6.0 deletes half of and relocates the rest of. A long-lived 6.0 branch would spend its life absorbing conflicts from our own fix stream, and the faster main moves, the more that branch costs.

What makes trunk-only survivable is that almost every break splits into an additive half and an irreducible half, and the additive half ships in 5.x under the normal rules:

Break Additive half — ships in 5.x Irreducible half — lands in the window
Method renames new names added, old ones warn old names removed
Public API __all__ declared, py.typed shipped privacy enforced
Error policy exception hierarchy added, silent Nones warn returns flip to raises
Legacy parser edgar.files deprecation warnings package deleted
Source-tree moves new paths behind import shims shims dropped
BeautifulSoup per-file lxml migrations dependency dropped
Two lanes: main receives commits continuously while the 5.x release stream pauses from October 12 to November 2, then 6.0 tags on November 9

By the time the freeze window opens around October 12, everything in the middle column should already be in your hands. The window itself — about three weeks — is when the right column lands on main, which goes knowingly unreleasable until 6.0 tags on November 9. A 5.x hotfix, if one is needed mid-window, branches from the last 5.x tag: a short-lived backport branch, the opposite shape from a feature branch running ahead of trunk. And if the window ever threatened to run past six weeks, that would be the signal a branch had become the cheaper option after all — the decision stays reversible in the cheap direction.

For you, the practical consequence is that the migration guide arrives early, as warnings. Update to each 5.x release as it comes, and run your suite with warnings promoted:

python -W error::DeprecationWarning -m pytest

Everything that trips is a 6.0 migration item, findable this fall with months to fix it. Only two breaks cannot warn in advance — the httpx exception namespace and the Python floor itself — and those two are precisely what the migration guide exists for.

What EdgarTools 6.0 breaks, and what each break buys

Every removal in the plan is paired with what it purchases.

The legacy HTML parser, edgar.files, is deleted — 904 KB across 11 modules, imported by 16 others, covered by 21 test files, all gone. The buy is one rendering pipeline instead of two that disagree. The legacy renderer has no concept of an image node, which is why every <img> in every filing vanished silently from Filing.markdown() until this month (GH #886) — NVIDIA's 10-K contains a stock-performance graph whose five-year return comparison exists only as that image, and the old path simply dropped it. The cutover has already happened additively on main; 6.0 just removes the fallback.

Silent None becomes a raised exception. The codebase currently holds 154 swallowed exceptions — 147 except: pass and 7 except: continue — each one a place where a failure turns into a quiet wrong answer. 6.0 ships a unified edgar.exceptions hierarchy and flips silent returns to raises. This is the same policy that came out of the 31,691-filing sweep that turns bug reports into regression tests: the worst failure mode in a data library is not the error, it's the plausible result delivered at full confidence.

The HTTP exception namespace changes. The migration to httpx2 moves the exception types your except clauses name, so EdgarTools will re-export the ones it raises through — you catch them from edgar.exceptions and stop caring what transport sits underneath.

The dependency tree goes on a diet. BeautifulSoup leaves entirely once the lxml migration completes. tabulate was unused and is dropped, GPL-licensed unidecode is replaced by anyascii (one fewer license to explain to your compliance team), textdistance consolidates into the already-present rapidfuzz, and the archived nest_asyncio retires. Alongside: 283 stray print statements in library code become logging, and the ~0.9 s import time is targeted with lazy imports.

The public API gets declared. __all__ on stable paths, a py.typed marker so your type checker finally sees the annotations, and enforced privacy for internals — which is what makes the next major version smaller than this one.

What leaves in 6.0: a 904 KB legacy parser, the BeautifulSoup dependency, 154 swallowed exceptions, 283 print statements, and one GPL license

If you depend on EdgarTools

Nothing breaks before October 12. Between now and then, the whole migration is three habits: stay current on 5.x rather than pinning and drifting, treat every DeprecationWarning as a 6.0 migration item with a deadline of mid-October, and — if you are stuck on Python 3.10 — know that the final 5.x will keep working indefinitely but stops receiving fixes when 6.0 ships.

If you'd rather be on the other side of the change: the last 100 merged pull requests came from more than 20 external contributors, most sending one to four PRs, and the roadmap was written to keep that door open. The BeautifulSoup-to-lxml migrations in particular are deliberately PR-sized — one file at a time, each with a before/after benchmark — and make ideal first contributions; the roadmap issues on GitHub mark them help-wanted.

The release post in November will publish the before/after numbers against the profiling baseline — the receipts for everything promised here. That's the standing rule for this release, and it is the line I'd put on the poster: performance is measured, not guessed, and every break earns its place.


🛠️
EdgarTools is free and open source. If it saved you time, star the repo on GitHub — it helps other developers find it.

Subscribe to EdgarTools

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe