Schemas and Schedule Metadata#

call-report holds two separate answers to “what fields does this schedule have”, and keeping them distinct is what makes them useful.

  • The canonical metadata is what the package believes a schedule looks like across its whole published history. It ships with the package, generated from FCA’s own archives, and is available whether or not you have any release files.

  • A layout is what one release actually declared. It comes from the D_<ROOT>.TXT file inside that quarter’s release, so it reflects that quarter and nothing else.

Both are reachable from FCACallReport, and both speak the same vocabulary (FieldSchema), so they can be compared directly.

Every example on this page uses PackagedArchiveTransport, which reads the release zips checked into this repository, so nothing here needs a download:

>>> from call_report.fca import FCACallReport
>>> from call_report.fca.transport import PackagedArchiveTransport
>>> report = FCACallReport(
...     start="2014-12-31", end="2015-03-31", transport=PackagedArchiveTransport()
... )

A schedule’s whole history#

get_file_metadata() returns a schedule’s canonical, cross-time FileMetadata. It does not depend on start, end, or transport, so it answers even before any file has been read:

>>> metadata = report.get_file_metadata(schedule="RI")
>>> metadata.name
'RI'
>>> metadata.first_period.label, metadata.last_period.label
('2000Q1', '2026Q1')
>>> len(metadata.file_schema)
46

RI has been published every quarter since 2000, but that does not mean its columns held still. changed reports whether any field’s presence differs from the file’s own:

>>> metadata.changed
True

Each field carries the periods it was actually present for, so you can see which ones came and went. NONTEMPIMPAIRN stopped being reported after 2014:

>>> impair = metadata.file_schema["NONTEMPIMPAIRN"]
>>> impair.first_period.label, impair.last_period.label
('2000Q1', '2014Q4')

That is why len(metadata.file_schema) is 46 while no single quarter of RI ever had 46 columns. The cross-time schema is the union of every field the schedule has ever had.

A single quarter#

get_schema() narrows that history to one quarter, returning a FieldSchema of just the fields present then:

>>> schema = report.get_schema(schedule="RI", period="2014-12-31")
>>> len(schema)
44
>>> schema.names[:6]
('SYSTEM', 'DIST', 'ASSOC', 'MONTH', 'YEAR', 'UNINUM')

The snapshot is historically accurate, not merely filtered. Each field is narrowed to the one version that applied at that date, so a field revised later still shows the definition in force at the quarter you asked for:

>>> provisions = schema["PROVLNS"]
>>> len(provisions.versions)
1
>>> provisions.versions[0].definition
'Provisions for Losses on Loans,Sales Contracts,Notes,and Leases'
>>> provisions.versions[0].periods[0].label
'2014Q4'

FieldSchema also exposes a plain narwhals schema, mapping name to dtype, for code that only cares about types:

>>> schema.schema["PROVLNS"]
Int64

Every quarter in range#

Omit period to get one schema per fetched quarter that has the schedule, keyed by ReportingPeriod:

>>> schemas = report.get_schema(schedule="RI")
>>> sorted(period.label for period in schemas)
['2014Q4', '2015Q1']

This is the quickest way to see a schedule’s width move over a range:

>>> {period.label: len(schema) for period, schema in schemas.items()}
{'2014Q4': 44, '2015Q1': 45}

get_layout() has the same shape, so the two can be used interchangeably.

What a release actually declared#

get_layout() reads the release’s own layout file, and to_field_schema() converts it into the same FieldSchema vocabulary the canonical metadata uses. A layout describes exactly one release and carries no period of its own, so the period has to be supplied:

>>> layout = report.get_layout(schedule="RI", period="2015-03-31")
>>> declared = layout.to_field_schema(period="2015-03-31")
>>> len(declared)
45

Comparing the two views#

Because both sides are a FieldSchema, compare() puts them side by side. This is the cheapest way to notice a release whose layout disagrees with the shipped metadata:

>>> canonical = report.get_schema(schedule="RI", period="2015-03-31")
>>> declared.compare(other=canonical).is_empty
True

An empty diff means the package’s belief about 2015Q1 matches what FCA actually published that quarter. A non-empty one is worth investigating: it means the shipped metadata has drifted from the releases it was generated from.

A field redefined in place#

A field can change without ever disappearing. RCF1’s LOANSTATUS holds a numeric code for each row’s loan-performance category, and FCA revised the code list itself in 2015, splitting code 155 into a new 152/155 pair. The field was present throughout, so its metadata carries two versions rather than a presence gap:

>>> loanstatus = report.get_file_metadata(schedule="RCF1").file_schema["LOANSTATUS"]
>>> len(loanstatus.versions)
2

as_of recovers whichever definition applied at a given quarter, which is what makes a snapshot usable as a description of that quarter’s data rather than of today’s:

>>> rcf1 = report.get_file_metadata(schedule="RCF1").file_schema
>>> rcf1.as_of(period="2010-03-31")["LOANSTATUS"].versions[0].definition[-32:]
'o OFIs 155 Other loans 160 Total'
>>> rcf1.as_of(period="2020-03-31")["LOANSTATUS"].versions[0].definition[-32:]
'o OFIs 152 Other loans 155 Total'

Tracking drift across quarters#

The same comparison across two different quarters shows how a schedule evolved. RI changed between 2014Q4 and 2015Q1:

>>> before = report.get_schema(schedule="RI", period="2014-12-31")
>>> after = report.get_schema(schedule="RI", period="2015-03-31")
>>> drift = before.compare(other=after)
>>> drift.added
('NONTEMPIMPAIR', 'ProvDebtSec')
>>> drift.removed
('NONTEMPIMPAIRN',)

changed holds the fields present in both but not identical, each as a FieldChange carrying the before and after metadata. FCA rewrote PROVLNS’s definition in that same quarter:

>>> change = next(item for item in drift.changed if item.name == "PROVLNS")
>>> change.before.versions[0].definition
'Provisions for Losses on Loans,Sales Contracts,Notes,and Leases'
>>> change.after.versions[0].definition
'Provisions for credit losses: On loans, sales contracts, notes, and leases'
>>> change.content_changed
True

One thing to know when comparing two quarters this way: each snapshot stamps its fields with its own quarter, so every field common to both quarters lands in changed on the strength of that stamp alone, whether or not its dtype or definition moved:

>>> len(drift.changed)
43

Most of that is span-only noise: the field is unchanged, only the quarter it was snapshotted at differs. periods_changed names that directly, and content_changed filters changed down to the fields whose dtype or definition actually moved:

>>> change.periods_changed
True
>>> [item.name for item in drift.content_changed]
['PROVLNS']

Comparing a layout against the canonical schema for the same quarter, as in the previous section, has no such stamp difference, so there content_changed and changed agree.

When metadata and a release disagree#

get_layout and get_schema answer from different sources, so they can in principle disagree. If a release contains a schedule the canonical metadata says was not published that quarter, get_schema raises PeriodNotAvailableError rather than pretending the schedule is missing:

PeriodNotAvailableError: file 'RCI' was not published as of 2025Q3;
known periods span 2000Q1 to 2017Q4.

That is a defect in the shipped metadata, and worth reporting. It is deliberately not reported as ScheduleNotFoundError, which would claim the release has no such schedule when it plainly does.

Next steps#

  • Get Started covers loading the data itself, and reshaping it to wide or long format.

  • API documents every method used here in full.