A Parquet lakehouse with DuckDB¶
Turn a folder of 835 files into partitioned Parquet that any engine can query. All of it is SQL
in DuckDB with the oxedi extension (see DuckDB to install it).
Read a folder¶
read_835 takes a path, a glob or a list. Add filename := true to know which file each row
came from. Row numbers restart in every file, so a child table joins its parent on the file
name and the row number.
SELECT filename, count(*) AS claims, sum(payment_amount) AS paid
FROM read_835('remits/*.835', table_name := 'claims', filename := true)
GROUP BY filename
ORDER BY filename;
Write Parquet partitioned by payer and month¶
One wide table of claims, with the payer and the payment date from the payment each claim belongs to, is the usual starting point.
CREATE TABLE claims_wide AS
SELECT
p.payer_name AS payer,
strftime(p.payment_date, '%Y-%m') AS month,
p.trace_number,
c.claim_id,
c.claim_status,
c.charge_amount,
c.payment_amount,
c.patient_responsibility,
c.filename
FROM read_835('remits/*.835', table_name := 'claims', filename := true) c
JOIN read_835('remits/*.835', table_name := 'payments', filename := true) p
ON p.filename = c.filename AND p."row" = c.payment;
COPY claims_wide TO 'claims_lake' (FORMAT parquet, PARTITION_BY (payer, month), OVERWRITE_OR_IGNORE);
The output has one folder per payer and month, such as
claims_lake/payer=ACME%20HEALTH%20PLAN/month=2024-01/ (values are URL-encoded). Read it back, with the partition values as
columns:
SELECT payer, month, count(*) AS claims, sum(payment_amount) AS paid
FROM read_parquet('claims_lake/**/*.parquet', hive_partitioning = true)
GROUP BY ALL
ORDER BY ALL;
Do the same for services and adjustments and join them on filename and the row
references, as in the query above.
Use diagnostics as a quality gate¶
Every file parses, and its problems come back as rows of the diagnostics table. Count them
before you publish a batch. ignore_errors := true turns a file that is not an 835 into a
row, so one bad file does not stop the run.
SELECT filename, level, rule, count(*) AS findings
FROM read_835('remits/*.835', table_name := 'diagnostics', filename := true, ignore_errors := true)
GROUP BY ALL
ORDER BY filename, level;
An empty result means the batch is clean. To stop a script at the first problem, make the count fail the statement:
SELECT CASE WHEN count(*) = 0 THEN 'clean'
ELSE error('835 diagnostics found: ' || count(*)) END AS gate
FROM read_835('remits/*.835', table_name := 'diagnostics', ignore_errors := true);
dbt sources over read_835¶
With dbt-duckdb, load the extension in the profile and build one staging model per table. The profile (not executed here):
oxedi_lake:
target: dev
outputs:
dev:
type: duckdb
path: lake.duckdb
extensions:
- name: oxedi
repo: community
A staging model, models/staging/stg_claims.sql:
{{ config(materialized='view') }}
SELECT *
FROM read_835('{{ var("remits_glob", "remits/*.835") }}', table_name := 'claims', filename := true)
A dbt singular test fails when it returns rows, so a test that selects the diagnostics fails the build on any finding:
-- tests/no_835_diagnostics.sql: a dbt singular test passes when it returns no rows
SELECT *
FROM read_835('{{ var("remits_glob", "remits/*.835") }}', table_name := 'diagnostics', filename := true)
The same two statements without the template, which is what dbt sends to DuckDB:
CREATE OR REPLACE VIEW stg_claims AS
SELECT *
FROM read_835('remits/*.835', table_name := 'claims', filename := true);
SELECT count(*) AS claims, sum(payment_amount) AS paid FROM stg_claims;
SELECT *
FROM read_835('remits/*.835', table_name := 'diagnostics', filename := true);