Skip to content

Upgrading to EdgarTools 6.0

This page lists every change in 6.0 that can alter what your code receives, one symbol at a time: what it did before, what it does now, and the rewrite where a mechanical one exists. It is written to be enough on its own — if you are an agent upgrading a dependent codebase, you should not need the CHANGELOG or the commit history as well.

6.0 is still in development. This page grows as each change lands, so a change absent here has not shipped. Items known to be coming are listed under Still to come so you can plan for them, but do not write code against them yet.

Nothing here is a rename

So far 6.0 changes what values you get back, not what anything is called. Every symbol on this page has the same import path and signature it had in 5.x. That makes these changes quiet: your code will not fail to import, and in most cases it will not raise — it will return something different.


Filing.cik and Filing.company on multi-filer filings

What changed. A filing with more than one filer now reports the issuer where it used to report whichever filer the quarterly index happened to list first, which was frequently the parent guarantor.

Resolving an accession — find(accession), get_by_accession_number(accession) — now goes through EDGAR full-text search, which orders a filing's filers issuer-first. The quarterly index ordered them differently.

from edgar import find

f = find("0001918704-25-005439")   # a BofA structured note

f.cik, f.company
# 5.x:  (70858,   'BANK OF AMERICA CORP /DE/')   <- the parent guarantor
# 6.0:  (1682472, 'BofA Finance LLC')            <- the issuer

What did not change. The set of filers is identical, and so is everything you can reach from the filing:

f.all_ciks       # [70858, 1682472] — same list, same order, both versions
f.all_entities   # still names both filers (the order differs)
f.text(), f.attachments, f.xbrl()   # identical; EDGAR serves the documents
                                    # under either CIK

Rewrite rule. If you read .cik or .company to identify the filer, decide which filer you actually want and say so:

# Before — implicitly "whichever filer the index put first"
issuer = filing.company

# After — say which one you mean
issuer = filing.company                       # the issuer, explicitly
every_filer = filing.all_entities             # all of them
if 70858 in filing.all_ciks:                  # membership, not identity
    ...

Single-filer filings — nearly all of them — are unaffected. Measured across 54 ground-truth accessions: 50 identical, 4 differing only in this way, none failing to resolve.

Scope. Filings from before 2001 are outside the full-text index and still resolve through the quarterly index unchanged, as does any lookup full-text search cannot answer.


TableNode.to_dataframe() row index

What changed. A table whose first column header spans several physical columns now returns a plain row index instead of None-padded tuples.

Filings routinely lay the row-label column across two or three physical columns that all carry the same header text, with only the first holding the label. 5.x moved every matching column into the index, so a label came back as a tuple.

table = document.tables[10]
df = table.to_dataframe()

list(df.index)[:1]
# 5.x:  [('Gross written premiums by line of business:', None, None)]
# 6.0:  ['Gross written premiums by line of business:']

df.loc['Gross written premiums by line of business:']
# 5.x:  KeyError
# 6.0:  works

Where the match came back two-dimensional, 5.x raised ValueError: Index data must be 1-dimensional outright and you got no frame at all.

Rewrite rule. Drop any tuple unpacking around index labels:

# Before
for label, row in df.iterrows():
    name = label[0] if isinstance(label, tuple) else label

# After
for name, row in df.iterrows():
    ...

Column count. The empty columns left behind by the spanning header are dropped, so most tables have the width they had in 5.x. A same-headed column that holds real data — a percentage or a rate — is kept as data rather than absorbed into the index, so a small number of tables are wider.

Measured across 1,940 tables in the benchmark corpus: 1,228 unchanged, 711 with a scalar index in place of tuples, 31 wider, one that used to raise and now converts, none returning less data. The corpus exposes the identical 1,026,693 non-blank cells before and after.


Document.to_dataframe()

What changed. It works. In 5.x it raised on essentially every real annual or quarterly report, from inside pandas or numpy, with one of:

ValueError: cannot join with no overlapping index names
TypeError:  Cannot cast array data from dtype('float64') to dtype('int64')
ValueError: Index data must be 1-dimensional

A filing's tables do not share a schema — one 10-K in the benchmark corpus has 71 tables whose column indexes are 1, 2, 3, 4, 10 and 17 levels deep — and pandas cannot align those. 6.0 flattens each table to single-level string column names before stacking them.

df = document.to_dataframe()

df.columns.nlevels          # always 1; every name is a str
df['_table_index']          # which table each row came from
df['_table_type']           # the table's classified type
df['_table_caption']        # its caption, or None

Rewrite rule. Remove any try/except you wrapped around this call, and drop MultiIndex column handling — df[('Revenue', '2024')] becomes df['Revenue 2024']. Header levels are joined with a space, with blanks dropped and repeats collapsed, so ('Revenue', '') and ('Revenue', 'Revenue') both become 'Revenue'.

When you want the real header structure, use the per-table API, which still preserves it:

document.tables[i].to_dataframe()   # keeps MultiIndex columns

FactQuery.to_dataframe() — the XBRL one

Two classes share this name

This applies to edgar.xbrl.facts.FactQuery, reached via filing.xbrl().facts.query(). It does not apply to edgar.entity.query.FactQuery, reached via company.get_facts().query(), which is unchanged in 6.0. Check which one you hold before changing code.

What changed. The column set now follows the query's configuration — the include_* flags and any names passed to to_dataframe() — and no longer varies with which rows matched.

q = filing.xbrl().facts.query()

q.limit(5).to_dataframe()
# 5.x: could return fewer columns than the same query unlimited, because
#      columns that happened to be null in those 5 rows were dropped
# 6.0: same columns either way; unpopulated ones come back null

q.by_period_type('instant').to_dataframe()
# 5.x: period_start / period_end disappeared
# 6.0: present and null

empty_result.to_dataframe()['decimals']
# 5.x: KeyError
# 6.0: an empty column

Rewrite rule. Remove defensive column checks:

# Before
if 'decimals' in df.columns:
    use(df['decimals'])

# After
use(df['decimals'])

Dtypes are unchanged for populated columns. A column with no data at all now takes its declared dtype rather than whatever inference landed on.


10-K items on filings with a Cross Reference Index

What changed. TenK.__getitem__ returned raw HTML on filings that route through a Cross Reference Index — Citigroup, GE and Henry Schein among them — while every other filing returned text. It now returns text everywhere.

tenk = filing.obj()
tenk['Item 1']
# 5.x (Citigroup): 1,685,461 chars starting '<div style="min-height:36pt...'
# 6.0:               218,550 chars of text, opening on the Business overview

Rewrite rule. If you were parsing the returned markup, stop:

# Before
from bs4 import BeautifulSoup
text = BeautifulSoup(tenk['Item 1'], 'html.parser').get_text()

# After
text = tenk['Item 1']

This affected filings unevenly — on Citigroup, Item 1A, Item 7 and Item 8 already returned text while Item 1 did not — so code that looked correct on one item could break on another.

Related. An item whose Cross Reference Index row reads "Not Applicable", or carries only an incorporation-by-reference marker, returns None rather than an empty string. There is no such disclosure in the document, and an empty section would present absence as content.


Section keys and section content

What changed. Section extraction was substantially corrected in 6.0. No API changed shape, but document.sections and tenk.items return different keys and different content on filings that were previously mis-parsed.

If you have hardcoded section keys, lengths, or offsets, re-check them. The changes that move the most:

  • Filings that group items out of numeric order (Morgan Stanley) had sections that ran to the wrong end or vanished entirely; they are now bounded correctly and the missing ones are present.
  • Filings using a Cross Reference Index (Citigroup) now expose canonical keys such as part_ii_item_7, where 5.x returned non-canonical keys like mda and gave nothing for the canonical name.
  • Filings writing item headings as single-row tables (Wells Fargo) went from one section of wrong content to all 23 items.

Rewrite rule. Prefer canonical keys and check for absence:

# Before — a key that only existed on some filings
mda = document.sections['mda'].text()

# After — the canonical key, absence handled
section = document.sections.get('part_ii_item_7')
mda = section.text() if section else None

Underwriter extraction on registration statements

What changed. RegistrationS1.underwriting returned wrong rosters in two directions, both now fixed. No API changed; the values did.

  • A firm whose legal name contains "division of" — EF Hutton, division of Benchmark Investments, LLC, ThinkEquity, a division of Fordham Financial Management, Inc. — was rejected as parser junk, so lead_manager was None on every filing it led.
  • A filing's beneficial-ownership table could be read as its underwriting syndicate, so lead_manager came back as the column header 'Before Offering' and the roster listed individual directors.

Rewrite rule. Remove None fallbacks and name filters you added to work around either:

# Before
lead = uw.lead_manager or guess_from_cover(filing)
firms = [u.name for u in uw.underwriters if looks_like_a_firm(u.name)]

# After
lead = uw.lead_manager
firms = [u.name for u in uw.underwriters]

Still to come

Planned for the 6.0 breaking window and not yet shipped. Do not write code against these; each will get a section above when it lands.

Change What to expect
edgar.files removal The legacy parser package and chunked_document go. Use edgar.documents.
httpxhttpx2 Exception namespace moves; code catching httpx.* will need updating.
Public API definition __all__, stable import paths, and internals made private — some currently-importable names will move.
Unified error policy Silent None returns become typed exceptions from an edgar.exceptions hierarchy.
Source tree reorganisation Module locations change; import paths follow.
FactQuery.to_dataframe() sentinels preferred_sign and fiscal_year move to nullable Int64.

Getting help

If something changed that is not on this page, that is a gap in this document rather than an intended surprise — please open an issue so it can be added.