Search Shortcut cmd + k | ctrl + k
quackiso

Query ISO 20022 (camt, pacs, pain) and SWIFT MT financial messages as SQL

Maintainer(s): tempoloss

Installing and Loading

INSTALL quackiso FROM community;
LOAD quackiso;

Example

-- Bank statements as rows: camt.053, camt.054 and camt.052. A glob is
-- parsed in parallel, one worker per file.
SELECT booking_date, amount, currency, credit_debit, counterparty_name
FROM read_iso20022('statements/*.xml', threads := 8)
ORDER BY booking_date;

-- What is in the folder, before choosing a reader: one row per file with
-- the message type, the reader that covers it, and the record count.
SELECT family, reader, count(*) AS files, sum(records) AS records
FROM sniff_iso20022('inbox/**/*.xml')
GROUP BY family, reader;

-- SWIFT MT is read the same way, by path: one row per :61: statement line.
-- A `:61:` states no currency of its own, so the statement's balance carries
-- it, along with the account, onto every entry.
SELECT account, value_date, credit_debit, amount,
       closing_balance_currency, closing_balance
FROM read_mt940('statements/*.txt')
ORDER BY value_date;

-- Interbank credit transfers (pacs.008, the ISO 20022 MT103).
SELECT uetr, amount, currency, debtor_name, creditor_agent_bic
FROM read_pacs008('pacs008.xml');

-- Credit transfer initiation (pain.001). The payer lives on the <PmtInf>
-- group and is carried down to every transaction in it.
SELECT payment_info_id, debtor_name, creditor_name, amount,
       requested_execution_date
FROM read_pain001('pain001.xml');

-- Direct debits (pain.008): the creditor pulls, the mandate makes it legal.
SELECT mandate_id, sequence_type, debtor_name, amount
FROM read_pain008('pain008.xml');

-- Payment returns (pacs.004): what came back, beside what was settled.
-- A return with charges deducted is amount < original_amount.
SELECT return_id, amount, original_amount, return_reason_code,
       original_debtor_name, original_creditor_name
FROM read_pacs004('pacs004.xml');

-- Payment status reports (pain.002). A status is stated per batch, per
-- payment group and per transaction, and status_level says which a row is.
SELECT status_level, status, reason_code, original_end_to_end_id, amount
FROM read_pain002('pain002.xml')
WHERE status_level = 'TRANSACTION';

-- Payment status requests (pacs.028): asking where a payment already sent
-- got to. A request states no status of its own, so every monetary column
-- is original_*, and scope says whether the row is one transaction or a
-- whole original message.
SELECT scope, original_msg_id, original_uetr, original_amount,
       original_settlement_date
FROM read_pacs028('pacs028.xml');

-- The mandate itself (pain.009), which a direct debit pulls against:
-- who may collect, from whom, how often and up to what.
SELECT mandate_request_id, sequence_type, frequency, creditor_name,
       debtor_name, collection_amount
FROM read_pain009('pain009.xml');

-- The investigation family (camt.027 here): the claim that money never
-- arrived. All seven investigation readers share the assignment and case
-- columns, so one case joins across the messages that answer it.
SELECT case_id, case_creator, assigner, assignee, original_amount,
       original_settlement_date
FROM read_camt027('camt027.xml');

-- Amounts are DECIMAL(38,5), so totals are exact.
SELECT currency, SUM(amount) AS total
FROM read_iso20022('statements/*.xml')
WHERE credit_debit = 'DBIT'
GROUP BY currency;

About quackiso

quackiso reads ISO 20022 and SWIFT MT financial messages directly into DuckDB tables. No Python preprocessing step, no per-schema glue code: point a table function at bank XML or at a folder of MT statements and get transactions as rows.

Thirty-four functions: thirty-three readers covering the payment lifecycle end to end, in both directions, and a sniffer that routes files to them.

  • read_iso20022(path) - camt.053 statements, camt.054 notifications and camt.052 reports; one row per booked entry.
  • read_camt057(path) - notifications to receive: money on its way in and not yet booked; one row per expected item, with the account carried down from its notification.
  • read_pacs008(path) / read_pacs009(path) - customer and financial-institution credit transfers (the ISO 20022 MT103 and MT202/MT202COV); in the COV form the underlying_* columns carry the customer transfer the cover settles.
  • read_pain001(path) / read_pain008(path) - credit transfer and direct debit initiation; the paying or collecting side lives on the <PmtInf> group and is carried down, and direct debits carry the mandate.
  • read_pain009(path) / read_pain010(path) / read_pain011(path) / read_pain012(path) - the mandate's own lifecycle: the authorisation a direct debit pulls against, its amendments, its cancellation and the report that accepts or refuses each of the three. One row per mandate record, with the amendment naming both the mandate it changes and what it becomes.
  • read_pain013(path) / read_pain014(path) - request to pay: the creditor asking the debtor to activate a payment, and the answer. The request's debtor and payment terms live on the <PmtInf> group and are carried down; the answer states its status at group, payment-info or transaction level, whichever the debtor's bank used.
  • read_pacs003(path) / read_pacs010(path) - the interbank leg of a direct debit collection, and the form where both sides are banks; there the collecting bank sits on the credit instruction and is carried down.
  • read_pain002(path) / read_pacs002(path) - payment status reports, customer- and interbank-side; one row per status statement, at whichever level the bank stated it, because a batch can be accepted or rejected without a single transaction being detailed.
  • read_pacs028(path) - payment status requests: the "where is my money?" message, asking another bank for the status of a payment already sent. A request carries no status of its own, so every monetary column is original_*, and a request naming a whole original message with no transaction detail is still a row.
  • read_pacs004(path) / read_pacs007(path) - returns and reversals: settled money coming back, from the receiver or taken back by the sender; the returned amount sits beside the original, so partial returns are visible.
  • read_camt056(path) / read_camt055(path) / read_camt029(path) - cancellation requests (interbank and customer-side) and the resolution that answers them; a whole-batch cancellation with no transactions is still a row.
  • read_camt027(path) / read_camt028(path) / read_camt030(path) / read_camt031(path) / read_camt036(path) / read_camt037(path) / read_camt087(path) - the investigation family: the claim that the money never arrived, the information that answers it, the notification that the case moved, the refusal, the debit authorisation request and its response, and the request to modify a payment rather than cancel it. All seven share the same assignment and case columns, so one case joins across them.
  • read_mt103(path) / read_mt202(path) / read_mt940(path) / read_mt942(path) - SWIFT MT, the FIN originals the pacs and camt messages replaced and banks still send: the customer transfer, the interbank transfer with its COV cover, the customer statement and the interim report. MT is flat tag-structured text rather than XML, so these share the scan and column machinery and nothing else; the statement readers carry the account and its balances onto every :61: line. MT carries no namespace, so the sniffer identifies it by its block structure instead, and routes it the same way it routes XML.
  • sniff_iso20022(path) - inventory before reading: one row per file with the detected message type, the family, the count of record elements a reader would turn into rows, and the reader that covers it. Identity comes from the namespace, the era-spelled container names, the envelope binding, or an MT block header, and content problems land in an error column instead of aborting the scan, so a folder of mixed downloads is a table rather than a failure.

All exception and status readers expose the original references (UETR, end-to-end id, original message id), so one payment joins across its whole lifecycle: initiation, settlement, status, cancellation, resolution, return.

Amounts are DECIMAL(38,5), never DOUBLE. Values are converted from the wire string straight to a scaled integer and never touch a float, so SUM is exact; ISO 20022 permits 18 significant digits with up to 5 fraction digits, which a 64-bit DECIMAL(18,5) cannot hold. An amount that cannot be represented exactly raises an error rather than becoming a NULL that would silently drop out of a total.

Dates are typed rather than left as text; offsets are normalised to UTC, and both the <Dt> and <DtTm> wrappings are read.

Parsing is a streaming pass over XML events, so the peak is one output batch plus the largest single XML subtree rather than the file: a 1.7 GB statement of three million entries parses in 1.23 MiB of live heap and about 2 MB resident, measured by cargo test --release membound -- --ignored, and adds 7.7 MiB to a running DuckDB. A glob is parsed in parallel, one worker per file - XML has no safe split points, so a single document is never divided - with threads := n to pin the pool, itself capped at four times the machine's parallelism; measured 6.9x on 8 files of 35 MB.

A file of the wrong message type fails loudly instead of returning an empty table. The transaction element names collide across families (camt.056 and pacs.004 both say TxInf, pacs.008 and pain.001 both say CdtTrfTxInf), so identity is the message's own container and rows are only produced inside it.

A file that is not XML at all is refused before it is buffered. An XML reader reads at most 64 KiB of the decompressed input first: no markup in that prefix, or a SWIFT MT block header ahead of any markup, is an error naming the reason. Rejecting an 8 MiB file costs what rejecting a 128 KiB one does, 0.86 MiB of live heap, because the reader never reaches the parser. Markup inside the prefix still goes to the parser, so a malformed document keeps its own diagnostics; sniff_iso20022 still reports a non-XML file as a row rather than failing the scan, and the read_mt* readers keep their own framing.

Tested against around 283 real messages from more than a dozen sources - Goldman Sachs US/UK/EU and wire, SIX interbank, CBPR+, ProgressSoft, Nivaes, Prowide, OpenBankProject, Mbanq, Handelsbanken, issettled, prog-nov, salesking and Dolibarr among them - spanning twenty-seven message families and every era of their vocabulary: renamed reason blocks, renamed containers, namespace-prefixed subtrees, group-level fields carried down, bare BICs where later editions put a party choice, and party sides that reverse in a return. Every behaviour that looks like a special case came from one of those files.

Paths are local files or globs; every row records its source_file. Remote URIs and XSD validation are deliberately absent, with the reasoning recorded in docs/adr/.

Added Functions

function_name function_type description comment examples
read_camt027 table NULL NULL  
read_camt028 table NULL NULL  
read_camt029 table NULL NULL  
read_camt030 table NULL NULL  
read_camt031 table NULL NULL  
read_camt036 table NULL NULL  
read_camt037 table NULL NULL  
read_camt055 table NULL NULL  
read_camt056 table NULL NULL  
read_camt057 table NULL NULL  
read_camt087 table NULL NULL  
read_iso20022 table NULL NULL  
read_mt103 table NULL NULL  
read_mt202 table NULL NULL  
read_mt940 table NULL NULL  
read_mt942 table NULL NULL  
read_pacs002 table NULL NULL  
read_pacs003 table NULL NULL  
read_pacs004 table NULL NULL  
read_pacs007 table NULL NULL  
read_pacs008 table NULL NULL  
read_pacs009 table NULL NULL  
read_pacs010 table NULL NULL  
read_pacs028 table NULL NULL  
read_pain001 table NULL NULL  
read_pain002 table NULL NULL  
read_pain008 table NULL NULL  
read_pain009 table NULL NULL  
read_pain010 table NULL NULL  
read_pain011 table NULL NULL  
read_pain012 table NULL NULL  
read_pain013 table NULL NULL  
read_pain014 table NULL NULL  
sniff_iso20022 table NULL NULL  

Overloaded Functions

This extension does not add any function overloads.

Added Types

This extension does not add any types.

Added Settings

This extension does not add any settings.