Skip to main content

KnackELT

Get your data out of Knack and into a real database — on a schedule, read-only, with full history.

Knack is a good place to run a business and a poor place to remember one. Every plan caps how many records you can hold, there is no SQL and no aggregates, and once a record is deleted it is gone.

KnackELT copies every record out through Knack's own REST API and keeps every version of every row. Nothing is ever overwritten, so the warehouse can still answer questions about records your app no longer has. It is built on dlt, and it never writes back to Knack.

How it fits together

flowchart TB
    subgraph src["SOURCE OF RECORD"]
        app["<b>Knack App</b><br/>where your team works"]
        api["<b>Knack REST API</b><br/>Knack's own data API"]
        app --> api
    end

    subgraph ing["INGESTION — this repo"]
        elt["<b>KnackELT</b><br/>pulls every record,<br/>never writes back"]
    end

    subgraph wh["DATA WAREHOUSE / DATABASE"]
        hist["<b>Complete history</b><br/>every version of every record —<br/>including ones deleted in Knack"]
        rep["<b>Reporting tables</b><br/>tidied into a shape you can<br/>filter, sort and add up"]
        hist -->|"modeled for reporting"| rep
    end

    subgraph bi["BI / DATA TOOLS"]
        dash["<b>Dashboards</b><br/>look up, drill down"]
        adhoc["<b>Ad-hoc + export</b><br/>new questions,<br/>Excel and CSV out"]
    end

    cron["<b>Scheduled run</b><br/>daily cron or CI job"]
    backup["<b>Offsite backup</b> — optional<br/>S3-compatible object storage:<br/>a third copy, outside both<br/>Knack and the warehouse"]

    api -->|"read-only"| elt
    cron -.->|"triggers"| elt
    elt -->|"keeps every version"| hist
    rep -->|"SQL"| dash
    rep -->|"SQL"| adhoc
    hist -.->|"optional"| backup

    classDef keep stroke:#d97706,stroke-width:3px
    classDef opt stroke-dasharray:5 5
    class hist keep
    class backup opt
    linkStyle 4 stroke:#d97706,stroke-width:2px

This repo is the INGESTION box. Everything flows one way: KnackELT reads through the same REST API your app already exposes, so it cannot alter or break anything in Knack. The amber box is the point of the exercise — your app deletes records to stay under its limit, and the warehouse keeps them anyway.

The other boxes are deliberately generic. KnackELT loads into anything dlt supports as a destination, and any BI tool that speaks SQL to that destination will do. For a concrete, working combination of all four — MotherDuck, dbt and Preset, orchestrated by a daily GitHub Actions job — see docs/ARCHITECTURE.md.

What it does

  • Discovers your schema. Reads the Knack application metadata and builds a resource per object, so there is no table list to maintain. Add an object in Knack and the next run picks it up.
  • Keeps schema identities stable. Physical tables and columns use Knack's immutable object_N and field_N keys, so renaming an app, object or field cannot split history or silently move current values. _kn_object_catalog and _kn_field_catalog keep the current human-readable labels beside those keys. Knack's own row id is loaded as record_id.
  • Cleans what the API hands back. Empty strings become NULL in numeric fields, and boolean fields get the default declared in Knack, so a column of numbers types as numbers rather than as text full of ''.
  • Keeps history. Loads with dlt's SCD2 merge strategy keyed on the Knack record id, so an edit retires the old row and appends a new one. Tables are kept flat (max_table_nesting=0) — one table per Knack object, no nested child tables.

Install

Requires Python 3.13+. Published on PyPI as knack-elt. Pick whichever fits:

Run it without installing

uv fetches the package into a throwaway environment, so this leaves nothing behind — the quickest way to point it at an app and see what comes out:

uvx --from knack-elt knack-elt run-pipeline --app-id your_app_id

Install the CLI

For repeated use, install it as a standalone tool. uv tool and pipx both keep it in its own environment rather than in your project or system site-packages:

uv tool install knack-elt      # or: pipx install knack-elt
knack-elt --version

Plain pip works too, though prefer a virtualenv over a system-wide install:

python -m pip install knack-elt

Clone for development

Use this if you intend to change the code. uv sync builds the environment from the lockfile, so you get the exact dependency versions CI tests against:

git clone https://github.com/mcmasty/knack-elt.git
cd knack-elt
uv sync
uv run knack-elt --version
uv run pytest tests/ -q      # offline: no Knack or MotherDuck credentials needed

In a clone, prefix the commands below with uv run. Installed via any of the other routes, call knack-elt directly.

Quick start

export KNACK_APP_ID=your_app_id
export KNACK_API_KEY=your_rest_api_key

knack-elt run-pipeline --app-id "$KNACK_APP_ID"

Your Knack REST API key comes from the Knack builder under Settings → API & Code. The pipeline only ever reads.

Physical names derive from the immutable app id, not the editable app slug. The CLI prints the safe {stable_app_id} it derives, then uses database knack_{stable_app_id}_data, dataset {stable_app_id}, and pipeline knack_{stable_app_id}_pipeline.

Destinations

--destination local (the default) writes a DuckDB file — nothing to sign up for, so a fresh install can be pointed at a Knack app and produce a queryable warehouse immediately.

The file goes to $XDG_DATA_HOME/knack-elt/knack_{stable_app_id}_data.duckdb, falling back to ~/.local/share/knack-elt/. That location is deliberately not relative to the working directory: the same app must keep one warehouse wherever you run the command, or a record's SCD2 history silently splits across directories. Pass --db-path to put it somewhere else. The resolved absolute path is printed on every run.

knack-elt run-pipeline --app-id "$KNACK_APP_ID" --db-path ~/knack.duckdb
knack-elt run-pipeline --app-id "$KNACK_APP_ID" --destination motherduck

--destination motherduck loads to md:///knack_{stable_app_id}_data and needs motherduck_api_key in the environment. Both destinations also write the run's _load_info, _trace, _kn_object_catalog, and _kn_field_catalog tables.

Naming migration: older releases derived databases, datasets, tables and columns from editable labels. Stable key-based naming intentionally starts a new physical namespace. Preserve an existing warehouse and migrate its history deliberately; do not delete the old DuckDB file or MotherDuck database after upgrading. On a local run, the CLI prints a note when a slug-named warehouse from an earlier release is still sitting beside the new one.

Other flags

Flag What it does
--api-key Knack REST API key, if you would rather not set KNACK_API_KEY
--refresh-metadata Re-fetch app metadata instead of reusing knack-sleuth's 24h on-disk cache
--skip-unreadable Log and continue past an object only when its first request returns HTTP 403. Authentication failures, rate limits, timeouts, server errors and failures after any row was yielded still abort the run.

Configuration

Read from the environment or a .env file via pydantic-settings (src/knack_elt/config.py):

Variable Purpose
KNACK_APP_ID Knack application id — also the default for --app-id
KNACK_API_KEY Knack REST API key, sent as X-Knack-REST-API-Key
motherduck_api_key MotherDuck token, when the destination is MotherDuck

Querying what you get

Because loads are SCD2, a record's history is several rows sharing one record_id, tagged with _dlt_valid_from and _dlt_valid_to. Two flags are worth deriving up front — conflating them is the most common way to get a wrong answer:

with flagged as (
    select
        *,
        row_number() over (partition by record_id order by _dlt_valid_from desc) = 1
            as latest_version,      -- one row per record
        _dlt_valid_to is null as is_live_in_knack   -- still in the app?
    from your_dataset.some_table
)
select * from flagged where latest_version

A record deleted in Knack survives only as a retired row, so filtering on _dlt_valid_to is null alone silently drops exactly the history you built the warehouse for. And aggregating without latest_version double-counts, because every past version is still a row. The architecture doc works through both.

Source-consistency caveat. Knack's record API pages by number, not by cursor, so a record inserted or deleted while a multi-page extraction is running shifts the page boundaries under it, and a record can slide across a boundary and be missed. A missed record can therefore be retired as though it were deleted.

The pipeline has a best-effort check. Every Knack page response carries a total_records, and a run that fetches fewer records than Knack reported it held throughout aborts instead of loading the batch — the merge never gets the chance to retire the missing rows. Re-run it and the load can succeed. Equal-count concurrent insertion/deletion can evade this test, so schedule snapshots during a quiet period. The window is widest on the biggest tables.

If Knack's response ever omits total_records, the check is skipped rather than failing closed, and the original hazard applies: a missed record is retired as deleted. That mostly self-corrects — the next run sees it again and re-adds it, so latest_version and is_live_in_knack is right again within a day, and a record would have to be missed the same way on consecutive runs to look durably gone. What does not self-correct is the history. The spurious retirement and re-add stay in the table permanently, so a point-in-time query (_dlt_valid_from <= d and (_dlt_valid_to is null or _dlt_valid_to > d)) over that window reports the record as deleted when it never was. Once is enough for that.

Confirmed-empty objects and objects removed from metadata are handled separately after a successful load: their remaining live rows are explicitly retired.

Documentation

  • docs/ARCHITECTURE.md — the reference architecture in plain language and in technical detail, the pipeline internals, a run sequence, and the SCD2 row model with the query patterns it requires. Also available as a PDF.

The PDF is generated from the markdown rather than maintained alongside it. After editing the diagrams, rebuild it with uv run scripts/build_architecture_pdf.py (needs node and Chrome) so the two don't drift apart.

  • dlt — the load framework this is built on
  • knack-sleuth — Knack application metadata models and schema export, used here to read your app's structure

License

GPL-3.0. See LICENSE.

Metadata

Release files for knack-elt 0.5.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for knack-elt 0.5.0
File Size Uploaded
knack_elt-0.5.0.tar.gz 33.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for knack-elt 0.5.0
File Interpreter ABI Platform
knack_elt-0.5.0-py3-none-any.whl Python 3 none any Details

Total release size: 64.0 kB

Release files / knack_elt-0.5.0.tar.gz

Download URL knack_elt-0.5.0.tar.gz
Size 33.2 kB
Tags Source
SHA-256 checksum
How to use checksums
e89bc7b189ddf084629f83f6c6a0f00d64f0121dea7dbde0c4042ec1f1b8ca99
BLAKE2b-256 checksum
How to use checksums
8c7fd57aa5271fbc37c0db36962d17fc62cbc255a5db099295a80d0d9ddbf6f9
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 26, 2026.

Transparency log

Release files / knack_elt-0.5.0-py3-none-any.whl

Download URL knack_elt-0.5.0-py3-none-any.whl
Size 30.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
91cb0572f730956e35fbf3600afceee9b87758b06f3a24a84030392dfa328f63
BLAKE2b-256 checksum
How to use checksums
7bd2929a0801e115511689c5d447b056fa0fc571b5c3fb784fcc5c4fefc1f64d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 26, 2026.

Transparency log

Release history Release notifications | RSS feed

0.6.1

2 release files

0.6.0

2 release files

This release

0.5.0 This release

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.2

2 release files

0.2.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page