ffiec-data-connect (Python)
DataNormalizer
Static class that reshapes REST API responses to match the legacy SOAP format — ZIP leading zeros, numeric-ID string coercion, datetime format normalization.
DataNormalizer is the compatibility shim that makes REST responses look
like SOAP responses. It’s applied automatically inside the collect_*
functions; you only touch it directly if you’re calling
RESTAdapter methods yourself.
All methods are @staticmethod — no instance state.
Why this exists
The FFIEC REST API returns typed JSON, which is mostly fine. Three places where it regresses against the SOAP payload format:
- ZIP codes lose leading zeros. REST returns
2886(int) for Warwick, RI. SOAP returned"02886"(str).DataNormalizercoerces to string and pads to five digits. - Numeric identifiers come back as integers.
ID_RSSD,FDICCertNumber,OCCChartNumber,OTSDockNumber,PrimaryABARoutNumberall arrive as ints; downstream consumers (databases, joins, string-keyed dicts) expect strings. SubmissionDateTimeformat varies. Normalized toMM/dd/yyyy HH:mm:ss AM/PMto match SOAP output.
Without this step, a pipeline migrating from SOAP to REST would silently break schema on three fields. With it, the REST payload is byte-compatible with SOAP for the endpoints that matter.
normalize_response
DataNormalizer.normalize_response(
data: Any,
endpoint: str,
protocol: str = "REST",
) -> Any
Main entry point. endpoint is the SOAP operation name — one of:
"RetrievePanelOfReporters""RetrieveFilersSinceDate""RetrieveFilersSubmissionDateTime""RetrieveReportingPeriods""RetrieveFacsimile"(preserves binary unchanged)"RetrieveUBPRReportingPeriods""RetrieveUBPRXBRLFacsimile"(preserves binary unchanged)
If protocol != "REST", the function returns data unchanged. If
endpoint is unknown, it logs a warning and returns data unchanged — so
forward-compatibility is safe, but don’t rely on normalization for an
endpoint not listed above.
from ffiec_data_connect import DataNormalizer, create_protocol_adapter, OAuth2Credentials
creds = OAuth2Credentials(username="...", bearer_token="eyJ...")
adapter = create_protocol_adapter(creds)
raw = adapter.retrieve_panel_of_reporters("12/31/2025")
clean = DataNormalizer.normalize_response(raw, "RetrievePanelOfReporters", "REST")
# clean[0]["ID_RSSD"] == "480228" (was 480228 int)
# clean[0]["ZIP"] == "02886" (was 2886 int)
# clean[0]["FDICCertNumber"] == "17546" (was 17546 int)
Exceptions from normalization are caught internally and logged; the
function returns the original data on failure, to avoid breaking a
pipeline over a formatting glitch. If you need strict behavior, validate
after the fact with validate_pydantic_compatibility().
TYPE_COERCIONS
Class-level dict mapping each endpoint to its per-field transforms:
DataNormalizer.TYPE_COERCIONS["RetrievePanelOfReporters"]
# {
# "ID_RSSD": lambda x: str(x),
# "FDICCertNumber": lambda x: str(x) if x is not None else "",
# "OCCChartNumber": lambda x: str(x) if x is not None else "",
# "ZIP": lambda x: DataNormalizer._fix_zip_code(x),
# "HasFiledForReportingPeriod": lambda x: str(x).lower() if x is not None else "false",
# ...
# }
Two special keys recognized by the dispatcher:
"_array_items"— when the response is a list of scalars (e.g.RetrieveFilersSinceDatereturnslist[int]), the dispatcher maps the coercion across the list."_preserve_binary": True— signals that the payload is raw XBRL bytes and must pass through unchanged.
You can monkey-patch this dict at runtime if you need to add normalizations for a field the library doesn’t know about yet — but prefer filing an upstream issue.
validate_pydantic_compatibility
DataNormalizer.validate_pydantic_compatibility(data: Any, endpoint: str) -> dict
Runs lightweight post-normalization validation:
- Numeric-ID fields are strings of digits.
- ZIP is exactly 5 digits.
- RSSD lists are all strings.
Returns:
{
"endpoint": str,
"compatible": bool,
"errors": list[str],
"warnings": list[str],
}
Useful as a canary in a CI or data-ingest check. Does not mutate.
get_normalization_stats
DataNormalizer.get_normalization_stats(original: Any, normalized: Any) -> dict
Diff-counter for debugging. Returns the count of objects changed, the approximate size delta, and the endpoint-level summary. Call it when you need to confirm normalization actually did something on a sample payload.
Internals (not public API, but useful to know)
_fix_zip_code(x)— the leading-zero fix. Acceptsint | str | None, returns a 5-char string or""._normalize_datetime(x)— MM/dd/yyyy HH:mm:ss AM/PM coercion._normalize_date_string(x)— MM/dd/yyyy coercion for period strings.
These are underscore-prefixed; don’t import them directly. They’re
documented here only because you’ll see them referenced in
TYPE_COERCIONS lambdas.
When normalization runs automatically
| Call site | Endpoint normalized |
|---|---|
collect_data | RetrieveFacsimile (binary passthrough) |
collect_filers_on_reporting_period | RetrievePanelOfReporters |
collect_filers_since_date | RetrieveFilersSinceDate |
collect_filers_submission_date_time | RetrieveFilersSubmissionDateTime |
collect_reporting_periods | RetrieveReportingPeriods |
collect_ubpr_reporting_periods | RetrieveUBPRReportingPeriods |
collect_ubpr_facsimile_data | RetrieveUBPRXBRLFacsimile (binary passthrough) |
If you’re using the collect_* surface, you’re already getting this for
free. Invoking DataNormalizer directly is only necessary when you call
RESTAdapter.retrieve_* yourself.
See also
- Client and adapters — how
RESTAdaptercallsDataNormalizerinternally. - Functions — the user-facing façade.
- Upstream
data_normalizer.py.