Why the standard method fails
Grafana seeds default rows when it creates the schema
When you start Grafana against an empty Postgres DB to get it to build the schema, Grafana also inserts default rows (org id=1, default admin user). When pgloader then loads your real data with with data only, it collides with those seeded rows:
ERROR Database error 23505: duplicate key value violates unique constraint "org_pkey"
DETAIL: Key (id)=(1) already exists.
CONTEXT: COPY org, line 1
Some timestamp values in the SQLite database can be represented in a format that pgloader/PostgreSQL cannot parse
Some rows get timestamps in Go’s internal string format, including a monotonic clock suffix Postgres can’t parse:
ERROR Database error 22007: invalid input syntax for type timestamp: "2026-04-30 11:44:40.5456473 +0530 IST m=+98527.488670601"
CONTEXT: COPY alert_rule, line 1, column updated: "2026-04-30 11:44:40.5456473 +0530 IST m=+98527.488670601"
On small tables, this fails the entire table’s load, not just the bad row → I ended up with zero alert rules migrated, with no error surfaced anywhere in the Grafana UI, just a silently empty “Alert rules” page.
Dashboards live in resource, not dashboard, on recent Grafana
On 12.x/13.x, unified storage moved dashboards/folders into resource/resource_history/resource_blob tables. If you check the old dashboard table and it’s empty, that’s expected
Steps →
1. Create the Postgres user and empty database, point Grafana at it, start it once.
sql
CREATE USER grafana WITH PASSWORD '<password>';
CREATE DATABASE grafana OWNER grafana;
Set [database] in grafana.ini (or custom.ini — see note below) to type=postgres with these credentials, then start Grafana and let it fully initialize. Stop it once it’s up.
2. Dump the schema only, then rebuild the database clean.
bash
pg_dump -U grafana -h <host> -d grafana --schema-only -f grafana-schema.sql
sql
DROP DATABASE grafana;
CREATE DATABASE grafana OWNER grafana;
bash
psql -U grafana -h <host> -d grafana -f grafana-schema.sql
This gives you the correct schema with no seeded rows .
3. Run pgloader.
load database
from sqlite:///path/to/grafana.db
into postgresql://grafana:<password>@<host>:5432/grafana
with data only, reset sequences
set work_mem to '16MB', maintenance_work_mem to '512MB';
bash
pgloader main.load
Check the final summary → every table should show errors: 0. Example from a clean run:
Total import time 6 2444 4.1 MB 3.356s
If any table shows errors > 0
4. Fix any table that failed due to timestamp parsing.
For each table pgloader reported errors on, e.g. alert_rule:
a) Get its column list in order:
bash
sqlite3 grafana.db "PRAGMA table_info(alert_rule);"
b) Export its rows as SQL insert statements:
bash
sqlite3 grafana.db <<'EOF'
.mode insert alert_rule
SELECT * FROM alert_rule;
EOF
c) Fix the timestamp values: find any string matching <date> <time> <±HHMM> <TZ> m=+<number> and replace it with just <date> <time><+HH:MM> (drop the timezone name and monotonic-clock suffix). Example:
2026-04-30 11:44:40.5456473 +0530 IST m=+98527.488670601
↓ becomes
2026-04-30 11:44:40.5456473+05:30
You can do this with sed across the whole export, or by hand if it’s only a few rows.
d) Watch for column type mismatches between SQLite and Postgres while inserting common ones →
Boolean columns: SQLite stores 0/1, Postgres wants false/true → cast explicitly.
BLOB columns: wrap the hex data in decode('<hex>', 'hex') instead of inserting raw.
Before writing ON CONFLICT, check the table’s actual unique constraints
\d tablename
(run this in psql) → match your ON CONFLICT (…) target to whichever constraint you’re likely to collide with. I hit this myself with kv_store, where the real constraint was (org_id, namespace, key), not id.
e) Insert with ON CONFLICT (<primary key or unique columns>) DO NOTHING so you can safely re-run without duplicate-key errors if some rows already loaded:
sql
INSERT INTO alert_rule (id, org_id, title, ...) VALUES (...)
ON CONFLICT (id) DO NOTHING;
Run →
bash
psql -U grafana -h <host> -d grafana -f fix_rows.sql
Repeat for each affected table. In my case this was alert_rule, alert_rule_state, signing_key, user_stats, resource_last_import_time — kv_store
5. Verify dashboards migrated correctly .
bash
sqlite3 grafana.db "SELECT resource, count(*) FROM resource GROUP BY resource;"
psql -U grafana -h <host> -d grafana -c "SELECT resource, count(*) FROM resource GROUP BY resource;"
Both outputs should match:
dashboards | 35
folders | 3
If they match, your dashboards are intact even though the old dashboard table is empty.
6. Start Grafana and do a final check.
Log in with an existing user → confirms user/org/org_user/permission migrated correctly
Dashboards list populates
Alerting → Alert rules shows your rules (check per-org if you have multiple orgs — alert rules are org-scoped, so if you expect N rules and see fewer, you may just be viewing the wrong org)
Alerting → Contact points shows your notification config intact