The Ultimate Guide: A Real-World Migration from Grafana 8 to 9 with PostgreSQL
Upgrading Grafana is a standard maintenance task, but moving from version 8 to version 9 is a significant leap. This isn't just a minor version bump; it's a fundamental change in Grafana's most critical feature: alerting. The deprecation of legacy dashboard alerts in favor of the new, centralized Unified Alerting system requires a careful, deliberate migration plan.
This guide provides a complete, battle-tested walkthrough based on a real-world migration. We won't just show you the ideal path; we'll show you how to do it safely by cloning your production environment first. Most importantly, we'll detail a sophisticated method to bypass all the common schema and data type errors by using Grafana 9 to create a "perfect" target schema before migrating any data.
The Core Challenge: A Two-Part Migration
The Grafana 8 to 9 upgrade is unique because it involves two distinct migrations:
- Database Backend Migration: Moving your configuration store from SQLite to a production-grade PostgreSQL database.
- Alerting System Migration: Converting all legacy panel-based alerts to the new Unified Alerting system, which is mandatory for Grafana 9.
Attempting to do both at once by simply changing the config and image tag is a recipe for failure. We will tackle this with a robust, controlled process.
The Game Plan: A Four-Phase Approach
Our strategy is designed to eliminate risk and ensure a perfect migration by tackling schema and data separately.
- Phase 1: Clone Production. We'll create an isolated, high-fidelity clone of your Grafana 8 (SQLite) instance in a new namespace. This is our risk-free sandbox.
- Phase 2: Generate the Perfect Target Schema. We'll cleverly use a temporary Grafana 9 instance to create a brand new, empty PostgreSQL database with the exact schema it needs, avoiding all legacy data type issues.
- Phase 3: The
pgloader Data-Only Migration. With a perfect schema in place, we will use the powerful pgloader tool to migrate only the data from our cloned SQLite database.
- Phase 4: The Final Launch. We will deploy the final Grafana 9 instance, which will start up cleanly against the fully migrated and schema-perfect database.
Phase 1: Clone Your Production Grafana (The Safe Way)
Rule #1: Never test in production. Our first step is to replicate the production environment, including its SQLite grafana.db file, in a new, isolated namespace.
(For detailed steps on this cloning process, including backing up manifests and using a utility pod to copy the grafana.db file from one PVC to another, please refer to our previous guide on Cloning a Grafana Instance on Kubernetes.)
At the end of this phase, you should have a fully functional clone of your Grafana 8 instance, running on SQLite in a dedicated namespace (e.g., grafana-upgrade-test).
Phase 2: Generate the Perfect Grafana 9 Schema
This is the most critical and innovative step. Instead of fighting with schema mismatches, we will have Grafana 9 create the perfect schema for us from scratch.
1. Deploy a Fresh PostgreSQL Instance
In your grafana-upgrade-test namespace, deploy a new PostgreSQL instance. At this point, it will contain an empty, default grafana database.
export NEW_NAMESPACE="grafana-upgrade-test"
kubectl apply -f postgres-secret.yaml -n $NEW_NAMESPACE
kubectl apply -f statefulset.apps.postgres-ss.yaml -n $NEW_NAMESPACE
kubectl apply -f service.postgres-svc.yaml -n $NEW_NAMESPACE
kubectl wait --for=condition=ready pod/postgres-ss-0 -n $NEW_NAMESPACE --timeout=300s
# Login to new postgres pod, delete and recreate the grafana DB
kubectl exec -n $NEW_NAMESPACE postgres-ss-0 -- bash -c 'dropdb -U postgres grafana && createdb -U postgres grafana'
2. Deploy a Temporary Grafana 9 Instance
We will now briefly start a Grafana 9 pod. Its only purpose is to connect to the empty database and run its initial migrations, thereby building the perfect schema.
-
Prepare the Manifests:
- Take your Grafana
deployment.yaml and change the image tag to grafana/grafana:9.5.21.
- Remove the PVC volume mount from the deployment. This instance is temporary and stateless.
- Ensure the database environment variables (
GF_DATABASE_...) point to your new PostgreSQL service.
-
Deploy and Let It Build:
kubectl apply -f grafana-config.yaml -n $NEW_NAMESPACE
kubectl apply -f deployment-grafana-v9-stateless.yaml -n $NEW_NAMESPACE
The pod will start, connect to Postgres, run all its v9 migrations, and then be in a Running state.
-
Dump the Perfect Schema and Tear Down: Once the pod is running, the schema is ready. We can now capture it and delete the temporary Grafana instance.
PG_POD="postgres-ss-0"
kubectl exec -n $NEW_NAMESPACE $PG_POD -- \
dropdb -U postgres grafana && createdb -U postgres grafana \
pg_dump --schema-only -U postgres grafana > perfect-v9-schema.sql
kubectl delete deployment grafana -n $NEW_NAMESPACE
You now possess a SQL file containing the exact table structures, column types (BOOLEAN, not BIGINT), and constraints that Grafana 9 expects.
Phase 3: The pgloader Data-Only Migration
Now we will combine our perfect schema with our original data.
1. Wipe and Prepare the Database
First, we wipe the database and restore only the perfect schema we just created.
kubectl cp perfect-v9-schema.sql $NEW_NAMESPACE/$PG_POD:/tmp/perfect-schema.sql
kubectl exec -n $NEW_NAMESPACE $PG_POD -- bash -c ' \
dropdb -U postgres grafana && \
createdb -U postgres grafana && \
psql -U postgres -d grafana -f /tmp/perfect-schema.sql \
'
2. Migrate Data with pgloader (Inside the Pod)
Our earlier attempts showed that running pgloader over the network via port-forward can be unstable due to Kubernetes liveness probes or network fragility. The most robust method is to run it directly inside the Postgres pod.
-
Copy Files to the Pod:
kubectl cp grafana.db $NEW_NAMESPACE/$PG_POD:/tmp/grafana.db
LOAD DATABASE
FROM sqlite://./grafana.db
INTO postgresql://postgres:GRAFANA2026@localhost:5432/grafana
WITH data only, reset sequences
SET work_mem to '16MB', maintenance_work_mem to '512 MB';
kubectl cp data-only.load $NEW_NAMESPACE/$PG_POD:/tmp/data-only.load
-
Exec and Run pgloader:
kubectl exec -n $NEW_NAMESPACE -it $PG_POD -- bash
root@postgres-ss-0:/
This process will now succeed. It will load the data from the SQLite file into the perfectly prepared tables. The warnings about mismatched data types are expected and a sign of success, indicating that pgloader is correctly casting BIGINTs to BOOLEANs.
Phase 4: The Final Launch and Verification
The database is now fully migrated and has the correct schema and data. We are ready for the final launch.
-
Deploy the Final Grafana 9 Instance: Use your Grafana 9 deployment manifest (the stateless one from Phase 2). No feature toggles are needed in the ConfigMap, as Unified Alerting is the default in Grafana 9.
kubectl apply -f grafana-config.yaml -n $NEW_NAMESPACE
kubectl apply -f deployment-grafana-v9-stateless.yaml -n $NEW_NAMESPACE
kubectl apply -f service.grafana.yaml -n $NEW_NAMESPACE
-
Monitor the Logs:
kubectl logs -f -l app=grafana -n $NEW_NAMESPACE
This time, Grafana will start, connect to the database, see that the schema is already at the latest version, and run its internal "legacy alert migration" on the data. Because the schema is perfect, this migration will succeed without error.
-
Verify Everything:
- Log in to the new Grafana 9 UI.
- Go to the Alerting page and confirm all your rules have been migrated.
- Check your key dashboards and data sources to ensure everything works as expected.
By using Grafana 9 to define our target and pgloader to handle the complex data transfer, we bypassed a chain of frustrating schema errors and achieved a clean, reliable migration.