Factcat

An open-source alternative to Amplitude and Mixpanel that runs in your own data warehouse. Factcat generates SQL and runs it in your BigQuery (or Snowflake, experimental) — no SDK, no ingestion, nothing hosted.

pip install factcat

Snowflake

Map one wide events table in your account. Factcat generates SQL and runs it there. It does not ingest rows. The mapping is written to .factcat.json in the directory you start factcat from; add .factcat.json to that repository's .gitignore.

Snowflake is experimental. The SQL is compiled against Snowflake's grammar in the test suite, but no live Snowflake account has executed it, so treat a first run as a test rather than a routine setup.

Credentials

Two sign-in methods. Neither stores a password in .factcat.json.

Key-pair (JWT). Create an RSA key, assign the public key to the Snowflake user, and put the path to the private key (rsa_key.p8) on this page. Encrypted keys use the environment variable SNOWFLAKE_PRIVATE_KEY_PASSPHRASE.

Browser SSO. Uses the connector's externalbrowser authenticator. The first query or catalog load opens a browser. After that, the connector caches the SSO token in the OS keyring (snowflake-connector-python[secure-local-storage]) until it expires (typically about four hours), if the account allows it:

ALTER ACCOUNT SET ALLOW_ID_TOKEN = TRUE;

That is an account-admin setting, not something Factcat can turn on. If caching is off, every new connection opens a browser again.

You also need an account identifier and user. After sign-in, Role (optional; blank is the user's default) and Compute warehouse are lists, then database → schema → table. Catalog fields stay visible and greyed until the previous step is set (same as BigQuery dataset → table). Each list loads as soon as the previous field is set the first time (database → schema → table → columns), then is cached. Returning to Setup does not re-query. Refresh on a field reloads that list. Do not type those names. Sign-in does not fill database, table, or grain; mapping persists as you pick fields. Events prompts until the mapping is ready.

Table shape

One table. One row per event. Properties are real columns. Event types that do not use a column leave it null (sparse). Not a VARIANT properties bag, and not one table per event type.

event_name     VARCHAR        -- 'purchase', 'page_view', …
occurred_at    TIMESTAMP_TZ   -- instant (offset stored)
-- or TIMESTAMP_LTZ           -- instant (UTC storage; session TZ is display)
-- or TIMESTAMP_NTZ           -- wall-clock, no zone (see Time)
account_id     NUMBER         -- null when the event has no account
country        VARCHAR
revenue        NUMBER

Flatten VARIANT in dbt before mapping.

Entity id

Whichever grain this report is about — not hard-coded to user_id. NUMBER preferred; VARCHAR is fine. Null means “not this grain”.

Timestamp

Must be an event time, not a calendar DATE and not a TIME-of-day.

Type What Snowflake stores Factcat
TIMESTAMP_TZ UTC + the offset from the value (not the IANA name) Instant. Reporting timezone is whose midnight is a day.
TIMESTAMP_LTZ UTC. Session TIMEZONE is display only Instant. Same.
TIMESTAMP_NTZ / DATETIME Wall-clock numbers, no zone Pick the zone those numbers are in (shown under Timestamp).
Bare TIMESTAMP Alias; default mapping is NTZ Treat as NTZ.
NUMBER / INT / BIGINT Unix epoch Instant. Unit (seconds / ms / µs) is inferred from a sample of values.

CONVERT_TIMEZONE with two arguments is for instants (TZ/LTZ). NTZ needs three arguments (source zone, target zone, value). We do not rely on the session TIMEZONE for NTZ. TIMESTAMP_TZ, TIMESTAMP_LTZ, and Unix epochs do not show a timezone picker.

Reporting timezone is whose midnight is a “day”, and whose Monday is a “week”. It and Week starts on live on Preferences, not here: they change the query, but they belong to whoever builds the report, so a colleague keeps their own.

Clustering

Snowflake has no partition column. Micro-partitions always exist; date filters often prune if rows are loaded in time order, with no CLUSTER BY. That is not Automatic Clustering — Automatic Clustering only maintains a key you already set.

When you do cluster a large events table, order keys from lowest cardinality to highest. Do not cluster on a raw nanosecond timestamp (too many values; use TO_DATE(event_time)). Do not cluster on month (too few values). A typical Factcat layout:

CLUSTER BY (event_name, TO_DATE(event_time))

A unique entity id as the leading key is usually too expensive to maintain. Setup lists views as well as tables.

Recommended: allow Factcat to create and maintain tables in a database and schema for better performance. Setup checks create rights on that schema (no test object).

Event name

Optional string column. Mapping persists as you pick fields; it does not query. Look back for event names on this page (90 days by default; 0 is all time) is the window for the Events picker. That job does not use the scan cap. Events Refresh event names re-runs it; the chevron next to it raises the window. The filter isolates the timestamp column so Snowflake can prune micro-partitions. First fetch creates fc_event_names if it is missing (materialized view, or a table if the source cannot back a view). Later Refresh reads the view, or rebuilds the table snapshot. The object stores a fingerprint of the mapped table and event-name column. Lookback does not apply (and is hidden here) while that dest is set. Catalog jobs do not use the scan cap. Events then filters event_name = '…'. The object is a census — names, rows per month, first and last seen. Snowflake materialized views refresh on their own schedule and need Enterprise edition; otherwise it is a table snapshot, and every Refresh of that table is a full recompute of the event-name and timestamp columns' history.

Factcat-managed tables

With a write database and schema set, Factcat keeps fc_column_index there: for a few breakdown columns, every row where the column had a value (entity, instant, value, event name). No clustering key is set: one would switch on Automatic Clustering, a standing serverless credit charge you did not ask for; micro-partitions prune on the time bounds as they do for your events table, and you can add a key yourself if the table grows large. The expensive Value-at modes read it plus the live rows after its bookmark, bounded on the bare timestamp column; results are exact regardless of how stale the index is.

Mode Automatic indexes a sparse column the first time an expensive mode uses it, on Snowflake as on BigQuery. Be deliberate about it here: Snowflake has no cost preview and no byte ceiling, so unlike BigQuery there is no scan cap standing behind the build, and that first run reads the column's whole history for every event name — several times what the chart alone would read. Factcat cannot cap Snowflake spend by bytes: each statement is cut off after the connection timeout (STATEMENT_TIMEOUT_IN_SECONDS, ten minutes by default) and nothing else bounds it. Point Factcat at a dedicated X-Small warehouse with AUTO_SUSPEND = 60, so an idle warehouse costs nothing and a runaway query costs at most ten minutes of X-Small. Snowflake also bills warehouse time rather than bytes, so the saving shows up as a shorter query, not a smaller bill, and a warm warehouse may show little. Set Mode to Off if that is not a trade you want — it stops the builds and the reads together, so charts go back to scanning the full history. Because there is no cost preview there is no estimate chip, and so no "Indexing…" copy while it runs; one line under the Run button afterwards says the index is in place. Refresh when older than decides when newer rows are folded in, Drop unused after drops columns no chart has used — checked during a chart Run, at most once a day, never in the background, and never for a column the current chart asks for. Late-arrival lookback is how late a row can land after its event time. Off stops Factcat using the indexes: charts read the full history and scan more. It also stops every build, refresh, rebuild and drop, and deletes nothing while it is off. Time spent off still counts toward Drop unused after, so switching back on resumes the ordinary clean-up — a column no chart has used for longer than that is dropped on the next Run. The chart you are running keeps its index either way.

Columns that are not text are left alone (the index stores text); write CAST(x AS STRING) as the breakdown expression to index one.

The table holds entity ids and column values copied from your events table, so check who can read the write schema before indexing a column that carries personal data. Rows deleted from the events table leave the index at the next refresh of that column, which depends on the event-name census noticing the row count fall — and without Enterprise materialized views that census is a table snapshot that only refreshes when someone clicks Refresh event names, so on standard edition the check is as fresh as your last refresh. With Mode: Off there are no refreshes and no clean-up at all, so deleted rows stay in the index until you Drop the column or the table yourself. Drop the column for an immediate removal. One gap to know about: if an event name that already existed starts carrying a column it never carried before, the index does not pick that up on its own — Drop the column and let the next chart rebuild it. The build statements for Snowflake are verified by compiling them against Snowflake's grammar in the test suite; no live Snowflake account ran them for this release. Result columns are lower-cased on the way back, because Snowflake reports unquoted identifiers upper-cased and every identifier Factcat generates is unquoted; a chart that comes back empty rather than wrong is the symptom of that going missing. If a build fails, the chart reads the full history and the Setup list says why.

The list shows size and age per table (from SHOW TABLES); Drop is the only per-table action (building, refreshing and rebuilding are Automatic mode's job). Its bookkeeping lives in .factcat.json on this machine, written the moment each column's build finishes; a mapping change rebuilds, a new event name with backfilled history is back-filled on the next refresh, a name whose row count shrank is rebuilt. If that file is ever lost while the table already holds real data, Factcat re-derives which columns and how far back from the table's own rows, matched by name — trusting the entity, table and timestamp mapping have not changed underneath it, single-install only. Needs CREATE TABLE on the schema (the rights check above). A failed build never blocks a chart: it runs on the full history and the run row says why.