posthog/querying-local-postgres
Run read-only SQL against the local Postgres app database (SELECT, EXPLAIN, EXPLAIN ANALYZE on SELECT). Default local URL postgres://posthog:posthog@localhost:5432/posthog; else DATABASE_URL. Use when querying the local DB, inspecting tables, debugging data, or analyzing query plans. Mutations are strictly forbidden.
npx skills add https://github.com/PostHog/posthog --skill querying-local-postgres
User's query: $ARGUMENTS
Scope: This repo uses PostgreSQL for app metadata (teams, projects, flags, Django models, etc.). Analytics event data lives in ClickHouse, not Postgres — use HogQL / ClickHouse tools for events-style questions unless the user explicitly wants Postgres.
EXPLAIN / EXPLAIN (ANALYZE, …) on read-only SELECT against Django or app tablesPGOPTIONS='-c default_transaction_read_only=on' to force a read-only connection).Do not run, suggest, or generate any of the following. Refuse and state that this skill is read-only.
INSERT, UPDATE, DELETE, MERGE, TRUNCATECREATE, DROP, ALTER, RENAMECOPY ... TO program, CALL (if it mutates), GRANT/REVOKEEXPLAIN ANALYZE on anything other than a read-only SELECT (including WITH … SELECT). Do not wrap DML in EXPLAIN ANALYZE — it would execute the write. The read-only connection below rejects writes, but the agent must not attempt this pattern.Allowed:
SELECT (including WITH … SELECT)EXPLAIN … SELECT (estimate-only plan; no execution)EXPLAIN (ANALYZE, …) SELECT — executes the SELECT once; use only for performance analysis. Must run on the read-only connection below.SHOW, SELECT from catalog views (pg_stat_*, information_schema, etc.) when read-onlyIf the user requests a write operation, say: "This skill is read-only. I can't run INSERT/UPDATE/DELETE or other mutations. Use a DB client or migration tool for writes."
| Goal | What to use |
| ----------------------------------------- | ------------------------------------------------------------------------------- |
| Plan shape, estimated costs, no execution | EXPLAIN (FORMAT TEXT, COSTS) or add VERBOSE |
| Actual timings, row counts, buffer hits | EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) on the SELECT |
| Buffer + WAL stats | BUFFERS requires ANALYZE; WAL requires ANALYZE (PostgreSQL 13+) |
Safe pattern: the analyzed statement must be only a SELECT (or WITH … SELECT), run on the read-only connection (see Usage below). Example:
PGOPTIONS='-c default_transaction_read_only=on' psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c "
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT TEXT)
SELECT … LIMIT 100;"
Optional flags (when useful): SETTINGS (show non-default GUCs), WAL (with ANALYZE), TIMING (default on in recent versions for ANALYZE).
Caveats:
EXPLAIN ANALYZE runs the query — can be slow or heavy on large scans; prefer a bounded SELECT (e.g. realistic WHERE, LIMIT matching production shape) when exploring.EXPLAIN without ANALYZE — does not execute the inner statement (except some special cases); still only wrap read-only SQL.DATABASE_URLUse this hardcoded URL for day-to-day local queries (matches typical Docker Compose + port 5432 on localhost, SSL off):
| Setting | Value |
| -------- | ----------- |
| Host | localhost |
| Port | 5432 |
| User | posthog |
| Password | posthog |
| Database | posthog |
| SSL | off |
# Prefer this unless the user says their local password/db differs
LOCAL_POSTGRES_URL='postgres://posthog:posthog@localhost:5432/posthog'
Equivalent: postgresql://posthog:posthog@localhost:5432/posthog
Other local DBs on the same server: swap the path only, e.g. ...5432/posthog_persons.
Configuration source of truth (app): posthog/settings/data_stores.py (Django DATABASES, optional replica POSTHOG_POSTGRES_READ_HOST, direct POSTHOG_POSTGRES_DIRECT_HOST, PERSONS_DB_WRITER_URL, product DB routing from products/db_routing.yaml).
When not using the hardcoded URL: Connecting from the host with the same credentials is documented in Developing locally (fe_sendauth troubleshooting). Ensure containers are running.
Default env when DEBUG is on: Django builds a default DATABASE_URL from PGHOST (default db), PGUSER / PGPASSWORD, PGPORT, PGDATABASE — matching in-container hostnames. From the host, use localhost and the same user/password/database name unless your shell already exports DATABASE_URL.
Multiple PostgreSQL databases (same server in local compose; separate logical DBs):
posthogposthog_persons (PERSONS_DB_WRITER_URL / PERSONS_DB_READER_URL)posthog_<name> per products/db_routing.yaml (created by docker/postgres-init-scripts/create-product-dbs.sh)docker/postgres-init-scripts/ if neededPoint psql at the right database by changing the path in DATABASE_URL (e.g. .../posthog_persons).
Rust / sqlx: Some services use rust/.env for DATABASE_URL when working from posthog/rust — see rust/README.md.
Always force the connection read-only via PGOPTIONS='-c default_transaction_read_only=on' so Postgres rejects writes even if the generated SQL is wrong.
> Why PGOPTIONS, not SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY?
> A psql -c "..." string with multiple statements runs as a single implicit transaction.
> SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY only sets the default for _subsequent_
> transactions — the in-progress one keeps the read-write mode it was given at BEGIN, so a write
> in the same -c would not be rejected. PGOPTIONS='-c default_transaction_read_only=on' sets
> the GUC at connection startup, so every transaction (including the implicit -c one) starts
> read-only. The inline equivalent is SET TRANSACTION READ ONLY; as the first statement of the
> -c string (it affects the current transaction, unlike SET SESSION CHARACTERISTICS).
Run from the PostHog repo root so relative env paths resolve.
Default — local hardcoded URL (posthog / posthog @ localhost:5432 / db posthog):
PGOPTIONS='-c default_transaction_read_only=on' psql "postgres://posthog:posthog@localhost:5432/posthog" -v ON_ERROR_STOP=1 -c "SELECT 1;"
Option A — DATABASE_URL already in the shell (e.g. after flox activate or manual export):
PGOPTIONS='-c default_transaction_read_only=on' psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c "SELECT 1;"
Option B — load from a gitignored env file at repo root (if DATABASE_URL is set there):
npx dotenv -e .env -- bash -c "PGOPTIONS='-c default_transaction_read_only=on' psql \"\$DATABASE_URL\" -c 'SELECT ...'"
-c.LIMIT 100 unless the user specifies otherwise.-x: psql ... -x -c "...".posthog/models/ (and product packages under products/). Table names are usually prefixed with posthog_ and snake-cased (e.g. posthog_team, posthog_user). Confirm with \dt posthog_* in psql, or check the model's Meta.db_table if nonstandard.posthog/migrations/ (and product migration paths) define the authoritative DDL over time.PERSON_TABLE_NAME (see data_stores.py); default posthog_person.deleted fields where applicable.POSTHOG_POSTGRES_READ_HOST).EXPLAIN ANALYZE on SELECT for slow Django queries replicated as SQL — mind loading production-sized data.docs/published/handbook/engineering/developing-locally.mdhogli (see .agents/skills/hogli/SKILL.md)Take posthog/querying-local-postgres from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.
The instructions reference npx.
Without those the skill loads but fails at the first command.