MySQL in Grafana: time series and table queries

The Grafana MySQL data source lets you put business numbers (orders per hour, revenue by city, cancelled orders) on the same screen as CPU and memory graphs. In this lesson you install MySQL 8 on app01, load a small online-shop database called shopdb, give Grafana a read-only login, and build time series, table, bar chart and pie chart panels from plain SQL.

You’ll use the macros that make SQL time-aware, such as $__timeFilter and $__timeGroupAlias, and you’ll meet the timezone problem that makes every Indian team’s graphs look five and a half hours off at least once.

Where this lesson fits

Lesson 6 of 14 in Grafana Dashboards and Observability · Phase 2: Data sources, logs and metrics · Before this: Lesson 5, dashboard variables · Next: Lesson 7, logs with Loki and Alloy

TimeYou needBy the end you can
About 85 minutesgraf01 and app01 from Lesson 5, about 400 MB of free disk on app01Connect Grafana to MySQL safely and build time series and non-time-series panels from SQL, with correct time zones

What it is, in one line

The MySQL data source lets Grafana run SELECT queries against a MySQL database and draw the rows as time series when there is a time column, or as tables, bars and pies when there isn’t.

In Simple Words

The kirana shop ledger

The kirana shop near your house writes every sale in a ledger: date and time, item, quantity, amount. At the end of the month the owner’s son wants a chart of sales per day for the festival week, so he doesn’t rewrite the ledger, he reads it and counts.

He only needs to read, so the owner gives him the ledger but keeps the pen. MySQL is the ledger, Grafana is the son counting sales, and the read-only database user is “you can read but you can’t write”.

Engineer's View

How the MySQL data source works

The MySQL plugin runs inside the Grafana server and connects with the Go MySQL driver to host:port, with the connection option loc=UTC and parseTime=true. Grafana expands its macros into plain SQL, sends the query, and converts the result rows into data frames. Since Grafana 13 the plugin ships separately from the core code and is updated on its own, but it is installed with Grafana.

MacroExpands to (MySQL)Use
$__timeFilter(created_at)created_at BETWEEN FROM_UNIXTIME(1794099600) AND FROM_UNIXTIME(1794121200)Only rows inside the dashboard time range
$__timeGroupAlias(created_at, 1h)UNIX_TIMESTAMP(created_at) DIV 3600 * 3600 AS “time”Buckets rows into time slots and names the column time
$__timeGroup(created_at, $__interval)The same bucket expression without the aliasWhen you want your own alias or GROUP BY
$__timeFrom() / $__timeTo()FROM_UNIXTIME(…) for the range endsCustom conditions, for example on a second column
$__unixEpochFilter(col)col >= 1794099600 AND col <= 1794121200Tables that store epoch seconds in an integer column

There are two result formats. Time series needs a column called time (a datetime or epoch seconds), sorted by time, plus numeric value columns; a text column becomes a label, so one query can draw one line per city. Table takes any result and is what bar charts, pie charts and tables use when the data has no time axis.

How it works, step by step

1Grafana connects as a read-only user

The data source stores host 192.168.56.71:3306, database shopdb, user grafana_ro and its password, encrypted in grafana.db. MySQL on app01 listens on the host-only address and ufw lets only graf01 in. The diagram below follows one panel query across both VMs.

2Macros become plain SQL

Before sending the query, Grafana replaces $__timeFilter(created_at) with a BETWEEN clause using the dashboard’s from and to times as UNIX seconds, and $__timeGroupAlias(created_at, $__interval) with an integer division that rounds every row down to its time bucket.

Grafana MySQL data source: one SQL panel from macros through ufw and the MySQL session to data frames

3MySQL runs it with the session time zone

FROM_UNIXTIME() and UNIX_TIMESTAMP() convert between epoch seconds and DATETIME using the session time zone. Grafana’s driver doesn’t set one unless you fill in “Session timezone” on the data source, so the server default applies, and that’s where IST vs UTC trouble starts.

4Rows become frames, frames become panels

With Format = Time series, Grafana groups rows by the text columns and builds one series per city or status. With Format = Table, the rows go to the panel as they are, and the visualisation decides: a bar chart wants one text column and one or more numbers, a pie chart wants a value per slice. The second diagram compares the shapes each panel type needs.

Grafana MySQL data source formats: time series, bar and pie chart, and table panels

5Keep the database safe and the queries cheap

Grafana runs every panel query again on each refresh for every viewer, so a dashboard with ten panels refreshing every 30 seconds for twenty people means about 400 queries a minute. Index the time column, filter with $__timeFilter so queries only touch the visible range, and point Grafana at a read replica when the main database is busy.

ProtectionHowWhy
Read-only userGRANT SELECT ON shopdb.* onlyA typo or a malicious query can’t change data
Network limitbind-address on the host-only IP, ufw from 192.168.56.70Only graf01 can even try to log in
Index on the time columnINDEX idx_created_at (created_at)$__timeFilter becomes a range scan, not a full table scan
Row limit[dataproxy] row_limit in grafana.ini (default 1000000)Stops a careless SELECT * from eating memory
ReplicaPoint the data source at a replicaHeavy dashboards don’t slow the shop’s checkout

Hands-on lab: shopdb on app01 and a Shop sales dashboard

Before you run any command, double-check it

Read every command twice before you press Enter. Check the target: the server name, the user, the disk or device name, the path and the sizes. These commands are for learning and were tested in our lab, not on your system. At work, run the read-only commands first, follow your change process, make sure a backup exists, and ask a second person to check anything that changes or deletes data. You run them at your own risk.

MySQL goes on app01, Grafana stays on graf01. Commands say which VM they run on. The database is a small online shop selling block-print kurtas and blue pottery mugs, with about a week of orders and one new order every minute.

1Install MySQL 8 on app01

Run this on app01:

sudo apt-get update
sudo apt-get install -y mysql-server
mysqld --version
systemctl is-active mysql

On Ubuntu 26.04 you should see:

/usr/sbin/mysqld  Ver 8.4.11-0ubuntu0.26.04.1 for Linux on x86_64 ((Ubuntu))
active

Ubuntu 26.04 ships MySQL 8.4 LTS, and 24.04 ships MySQL 8.0 (its line reads Ver 8.0.46-0ubuntu0.24.04.4 or a later 8.0 release). Every command in this lesson works on both. On Ubuntu the MySQL root account uses socket authentication, so sudo mysql logs you in as root without a password.

✅ Checkpoint: The version check prints an 8.x version and the mysql service is active on app01.

2Create shopdb with a week of orders

Paste this whole block on app01:

sudo mysql <<'SQL'
CREATE DATABASE IF NOT EXISTS shopdb;
USE shopdb;

CREATE TABLE products (
  id        INT AUTO_INCREMENT PRIMARY KEY,
  name      VARCHAR(60)  NOT NULL,
  category  VARCHAR(30)  NOT NULL,
  price     DECIMAL(10,2) NOT NULL
);

CREATE TABLE orders (
  id          BIGINT AUTO_INCREMENT PRIMARY KEY,
  created_at  DATETIME NOT NULL,
  product_id  INT NOT NULL,
  city        VARCHAR(30) NOT NULL,
  qty         INT NOT NULL,
  amount      DECIMAL(10,2) NOT NULL,
  status      ENUM('paid','shipped','cancelled') NOT NULL DEFAULT 'paid',
  INDEX idx_created_at (created_at),
  FOREIGN KEY (product_id) REFERENCES products(id)
);

INSERT INTO products (name, category, price) VALUES
  ('Block print kurta', 'Clothing', 899.00),
  ('Blue pottery mug',  'Home',     349.00),
  ('Leather mojari',    'Footwear', 1299.00),
  ('Bandhani dupatta',  'Clothing', 649.00),
  ('Brass diya set',    'Home',     499.00);

-- About 7 days of history: one order every 10 minutes (1000 rows), times in UTC
INSERT INTO orders (created_at, product_id, city, qty, amount, status)
WITH RECURSIVE t(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM t WHERE n < 1000)
SELECT UTC_TIMESTAMP() - INTERVAL n*10 MINUTE,
       1 + FLOOR(RAND()*5),
       ELT(1 + FLOOR(RAND()*4), 'Jaipur', 'Delhi', 'Mumbai', 'Pune'),
       1 + FLOOR(RAND()*3),
       0,
       ELT(1 + FLOOR(RAND()*10), 'paid','paid','paid','paid','paid','shipped','shipped','shipped','shipped','cancelled')
FROM t;

UPDATE orders o JOIN products p ON p.id = o.product_id SET o.amount = p.price * o.qty;
SQL
sudo mysql shopdb -e "SELECT COUNT(*) FROM orders; SELECT status, COUNT(*) FROM orders GROUP BY status;"

You should see (your status counts will differ):

COUNT(*)
1000
status	COUNT(*)
cancelled	86
shipped	412
paid	502

Your status counts differ because RAND() picks them, but the total is always 1000. Every created_at is written with UTC_TIMESTAMP(), so the table holds UTC times; keep that in mind for the Break It section.

✅ Checkpoint: shopdb.orders has 1000 rows on app01, with paid, shipped and cancelled statuses.

3Add one new order every minute with the event scheduler

Run this on app01:

sudo mysql shopdb <<'SQL'
CREATE EVENT IF NOT EXISTS ev_new_order
  ON SCHEDULE EVERY 1 MINUTE
  DO
    INSERT INTO orders (created_at, product_id, city, qty, amount, status)
    SELECT UTC_TIMESTAMP(), p.id, ELT(1 + FLOOR(RAND()*4), 'Jaipur','Delhi','Mumbai','Pune'), 1, p.price, 'paid'
    FROM products p ORDER BY RAND() LIMIT 1;
SQL
sleep 65
sudo mysql shopdb -e "SELECT @@event_scheduler; SELECT id, created_at, city, amount FROM orders ORDER BY id DESC LIMIT 2;"

You should see (your IDs, times, cities and amounts will differ):

@@event_scheduler
ON
id	created_at	city	amount
1002	2026-11-08 06:42:44	Mumbai	649.00
1001	2026-11-08 06:41:44	Pune	349.00

The event scheduler is on by default in MySQL 8, so the event starts firing at once. Your shop now has live data, and a “Last 15 minutes” graph keeps moving.

✅ Checkpoint: Two new rows appear one minute apart and @@event_scheduler shows ON.

4Create the read-only user grafana_ro

Run this on app01 (choose your own password):

sudo mysql <<'SQL'
CREATE USER 'grafana_ro'@'192.168.56.70' IDENTIFIED BY 'Ro-Pass-2026';
GRANT SELECT ON shopdb.* TO 'grafana_ro'@'192.168.56.70';
SHOW GRANTS FOR 'grafana_ro'@'192.168.56.70';
SQL

You should see:

Grants for grafana_ro@192.168.56.70
GRANT USAGE ON *.* TO `grafana_ro`@`192.168.56.70`
GRANT SELECT ON `shopdb`.* TO `grafana_ro`@`192.168.56.70`

The host part @192.168.56.70 means this account can only log in from graf01. USAGE is MySQL’s word for “no global privileges”, and SELECT on shopdb.* is the only real right it has.

✅ Checkpoint: SHOW GRANTS lists USAGE on *.* and SELECT on shopdb.* for grafana_ro@192.168.56.70.

5Let MySQL listen on the host-only network, for graf01 only

Ubuntu’s MySQL listens on 127.0.0.1 only (bind-address = 127.0.0.1 in /etc/mysql/mysql.conf.d/mysqld.cnf). Change it to app01’s host-only address and open port 3306 to graf01 alone.

Run this on app01:

sudo sed -i 's/^bind-address.*/bind-address = 192.168.56.71/' /etc/mysql/mysql.conf.d/mysqld.cnf
sudo systemctl restart mysql
sudo ss -ltnp | grep 3306
sudo ufw allow from 192.168.56.70 to any port 3306 proto tcp
sudo ufw status | grep 3306

You should see (your PID will differ):

LISTEN 0      151    192.168.56.71:3306       0.0.0.0:*    users:(("mysqld",pid=5120,fd=23))
3306/tcp                   ALLOW       192.168.56.70

Then test from graf01:

sudo apt-get install -y mysql-client
mysql -h 192.168.56.71 -u grafana_ro -p shopdb -e "SELECT COUNT(*) FROM orders;"
Enter password:
COUNT(*)
1003

The login works from graf01 with SELECT rights. sudo mysql on app01 still works too, because it uses the local socket, not the network address.

✅ Checkpoint: mysql from graf01 as grafana_ro returns the order count, and ss on app01 shows mysqld on 192.168.56.71:3306.

6Add the MySQL data source in Grafana

In Grafana open Connections > Data sources > Add new data source > MySQL and fill in:

FieldValue
NameMySQL app01
Host URL192.168.56.71:3306
Database nameshopdb
Username / Passwordgrafana_ro / your password
Session timezone+00:00
Max open / idle connections10 / 5 (defaults are fine)

Write the host as host:port without mysql:// or http://. Click Save & test; you should see Database Connection OK. Setting the session timezone to UTC matches how the table stores times, and the Break It section shows what happens without it.

Grafana MySQL data source: MySQL app01 settings on graf01 with Database Connection OK
The MySQL app01 data source after Save & test: host 192.168.56.71:3306, database shopdb, user grafana_ro and session timezone +00:00.

✅ Checkpoint: Save & test on “MySQL app01” shows Database Connection OK.

7Orders per hour as a time series

Create a new dashboard Shop sales in folder SaatVedha Lab. Add a panel, choose MySQL app01, switch the editor from Builder to Code, set Format to Time series and paste:

SELECT
  $__timeGroupAlias(created_at, 1h),
  COUNT(*)     AS orders,
  SUM(amount)  AS revenue
FROM orders
WHERE $__timeFilter(created_at)
  AND status <> 'cancelled'
GROUP BY 1
ORDER BY 1

Set the time range to Last 6 hours and press Ctrl+Enter (Cmd+Return on a Mac). You get two lines, orders and revenue. Move revenue to the right axis with an override (Fields with name: revenue, Axis > Placement: Right, Unit: Currency > Indian Rupee (₹)) so the small order counts aren’t flattened.

Title it Orders and revenue per hour.

The same query as Grafana sends it, run by hand on graf01 (macros expanded for the last 6 hours):

F=$(date -u -d '-6 hours' +%s); T=$(date -u +%s)
mysql -h 192.168.56.71 -u grafana_ro -p shopdb -e "
SELECT UNIX_TIMESTAMP(created_at) DIV 3600 * 3600 AS \"time\", COUNT(*) AS orders, SUM(amount) AS revenue
FROM orders WHERE created_at BETWEEN FROM_UNIXTIME($F) AND FROM_UNIXTIME($T) AND status <> 'cancelled'
GROUP BY 1 ORDER BY 1;"

The output looks like this, with your own times and numbers, because RAND() made the orders:

time	orders	revenue
1794099600	4	5794.00
1794103200	5	9188.00
1794106800	5	6842.00
1794110400	6	14134.00
1794114000	6	9588.00
1794117600	2	1396.00

The time column holds epoch seconds rounded to the hour, which is what the macro produces. Open the panel’s Query inspector > Query tab and you’ll see Grafana’s own expanded SQL looks the same.

✅ Checkpoint: The panel shows orders and revenue per hour for the last 6 hours, with revenue on a right-hand rupee axis.

Grafana MySQL data source lab: hourly orders query as grafana_ro and an access denied 1045 error
The hourly orders query run as grafana_ro from graf01, and the 1045 error with a wrong password.

8One line per city, using $__interval

Add another time series panel. A text column in a time series query becomes a label, so this draws one line per city:

SELECT
  $__timeGroupAlias(created_at, $__interval),
  city,
  COUNT(*) AS orders
FROM orders
WHERE $__timeFilter(created_at)
GROUP BY 1, 2
ORDER BY 1

In the panel’s Query options set Min interval to 10m, because new rows arrive every minute and history every 10 minutes; smaller buckets would be full of zeros and ones. Zoom out to 7 days and the buckets grow by themselves through $__interval.

✅ Checkpoint: The panel shows four lines named Jaipur, Delhi, Mumbai and Pune, and the bucket size changes when you change the time range.

9Panels without a time axis: table, bar chart, pie chart

Add three panels with Format: Table. They still filter with $__timeFilter so they follow the time picker, but they don’t return a time column.

PanelVisualisationSQL
Revenue by categoryBar chartSELECT p.category, SUM(o.amount) AS revenue FROM orders o JOIN products p ON p.id = o.product_id WHERE $__timeFilter(o.created_at) AND o.status <> ‘cancelled’ GROUP BY p.category ORDER BY revenue DESC
Orders by statusPie chartSELECT status, COUNT(*) AS orders FROM orders WHERE $__timeFilter(created_at) GROUP BY status
Latest 10 ordersTableSELECT o.created_at, p.name, o.city, o.qty, o.amount, o.status FROM orders o JOIN products p ON p.id = o.product_id ORDER BY o.id DESC LIMIT 10

For the pie chart set Value options > Show to All values, so each row becomes a slice. For the table, add a Cell type: Colored text override on status with value mappings paid = green, shipped = blue, cancelled = red.

Check the bar chart numbers on graf01 (last 6 hours):

mysql -h 192.168.56.71 -u grafana_ro -p shopdb -e "
SELECT p.category, SUM(o.amount) AS revenue FROM orders o JOIN products p ON p.id=o.product_id
WHERE o.created_at BETWEEN FROM_UNIXTIME($F) AND FROM_UNIXTIME($T) AND o.status<>'cancelled'
GROUP BY p.category ORDER BY revenue DESC;"

The output looks like this (your amounts will differ):

category	revenue
Footwear	22083.00
Clothing	14432.00
Home	10427.00
Grafana MySQL data source: Shop sales dashboard with orders per hour, per-city lines, revenue by category, status pie and latest orders
The finished Shop sales dashboard for the last 6 hours: orders and revenue per hour, one line per city, revenue by category, orders by status and the latest orders.

✅ Checkpoint: Shop sales has five panels: two time series, a bar chart, a pie chart and a coloured latest-orders table.

10Try the SQL builder and a city variable

Add a Query variable city on MySQL app01 with the query SELECT DISTINCT city FROM orders ORDER BY city, Multi-value and Include All on. In the per-city panel add AND city IN ($city) to the WHERE clause; for SQL data sources Grafana formats a multi-value selection as a quoted, comma-separated list, so IN works directly.

Then open a new panel and stay in Builder mode: pick table orders, column amount with aggregation SUM, filter status != cancelled and group by city. Click Code to see the SQL the builder wrote. The builder is fine for quick questions, while Code is better for joins and macros.

✅ Checkpoint: Picking Jaipur and Pune in the City drop-down leaves only those two lines in the per-city panel.

Break It, Then Fix It

All three are common tickets when a team first connects Grafana to a database. Each one is safe to try on these VMs.

1Access denied for user

On the data source page, change the password to something wrong and click Save & test. Then put the right password back but change the user to grafana_rw.

You’ll get this error in Grafana:

[auth] MySQL rejected the configured account. Verify the username, password, and account access. MySQL error number: 1045.

The same failure from the command line on graf01:

mysql -h 192.168.56.71 -u grafana_ro -pWrongPass shopdb -e "SELECT 1"
mysql: [Warning] Using a password on the command line interface can be insecure.
ERROR 1045 (28000): Access denied for user 'grafana_ro'@'192.168.56.70' (using password: YES)

Error 1045 means MySQL found no account matching that user, host and password. The host part counts: if you created grafana_ro@localhost by mistake, graf01 can never log in as it, however correct the password is. Older plugin versions show the raw driver message “Error 1045 (28000): Access denied for user …” instead of the [auth] text; the number is what matters.

Fix: check the account and host on app01, then re-enter the password in Grafana:

sudo mysql -e "SELECT user, host FROM mysql.user WHERE user LIKE 'grafana%';"
user	host
grafana_ro	192.168.56.70

✅ Checkpoint: Save & test shows Database Connection OK again with user grafana_ro.

2“db has no time column”

Edit the orders-per-hour panel and give the time column a different alias, as people do when they copy SQL from a report:

SELECT
  $__timeGroup(created_at, 1h) AS hour_bucket,
  COUNT(*) AS orders
FROM orders
WHERE $__timeFilter(created_at)
GROUP BY 1
ORDER BY 1

You’ll get this error on the panel:

db has no time column: time column is missing; make sure your data includes a time column for time series format or switch to a table format that doesn't require it

With Format = Time series, Grafana looks for a column named time (or the old time_sec). $__timeGroupAlias adds AS "time" for you, while $__timeGroup plus your own alias doesn’t. A query that genuinely has no time axis, such as revenue by category, should use Format = Table instead.

Fix: name the column time, or use the Alias macro:

SELECT $__timeGroupAlias(created_at, 1h), COUNT(*) AS orders
FROM orders WHERE $__timeFilter(created_at) GROUP BY 1 ORDER BY 1

✅ Checkpoint: The panel draws orders per hour again without an error.

3Everything is 5 hours 30 minutes off

A colleague sets Session timezone on the data source to +05:30 “because we’re in India”. Save, then look at the Last 1 hour view of the per-city panel.

You’ll get this:

Last 1 hour:   No data
Last 12 hours: the newest orders appear about 5.5 hours in the past

The table stores UTC wall-clock times in a DATETIME column, which has no time zone of its own. Grafana sends the range as epoch seconds, and with an IST session MySQL turns those into IST wall-clock times, so the filter looks for rows dated 11:13 to 12:13 while the newest row says 06:42. The same thing happens the other way round when an application writes NOW() on a server set to IST and Grafana reads it as UTC.

See it on app01 with the same epoch range in both session zones:

F=$(date -u -d '-1 hour' +%s); T=$(date -u +%s)
for tz in '+00:00' '+05:30'; do
  sudo mysql shopdb -e "SET time_zone='$tz'; SELECT '$tz' AS session_tz, COUNT(*) AS orders_last_hour,
    FROM_UNIXTIME($F) AS range_from FROM orders WHERE created_at BETWEEN FROM_UNIXTIME($F) AND FROM_UNIXTIME($T);"
done
session_tz	orders_last_hour	range_from
+00:00	66	2026-11-08 05:43:37
session_tz	orders_last_hour	range_from
+05:30	0	2026-11-08 11:13:37

Fix: make the session zone match how the column was written:

Data source > Session timezone: +00:00     (rows written with UTC_TIMESTAMP())
Dashboard settings > Time zone: Browser Time  (people still see IST on screen)

Store times in UTC, tell the data source the column is UTC, and let the dashboard show the viewer’s local time. If an existing application writes IST, set the session timezone to +05:30 instead; the rule is “match the writer”, not “match the office”.

✅ Checkpoint: Last 1 hour shows the newest orders again, and the time axis shows IST in your browser.

This happened at work

The sales dashboard that said the shop closed at 6:30

A friend who was the only DevOps engineer at an e-commerce startup in Jaipur had this in her first month. The founders loved the new Grafana sales board on the office TV, until the head of operations pointed out that it showed zero orders after 6:30 pm every evening, the exact hours when most of their customers shopped.

She first thought the event that copied orders into the reporting database was failing at night, and spent an evening reading its logs, which were clean. Then she checked the reporting tables directly with SELECT MAX(created_at) and found orders up to 11:45 pm, so the data was there and Grafana wasn’t showing it.

The cause was two different time zones. The application server ran in IST and wrote NOW() into DATETIME columns, while Grafana’s MySQL connection assumed UTC. Every order appeared five and a half hours late, so “now” on the graph was in fact 6:30 pm, and everything after that sat in the future, off the right edge of the panel.

She set the data source’s session timezone to +05:30 as a quick fix that same evening, and the TV filled in. The lasting change took a sprint: the developers moved the application to write UTC timestamps, the session timezone went back to UTC, and the team added a “latest order age” stat panel that turns red if the newest row is more than ten minutes old.

Common mistakes and troubleshooting

MistakeWhat happensWhat to do instead
Connecting Grafana as root or the app userA wrong query or a stolen Grafana password can change or delete dataCreate a user with SELECT only on the database Grafana needs
Creating the user for localhost onlyAccess denied 1045 from the Grafana serverCreate the user for the Grafana server’s IP, for example grafana_ro@192.168.56.70
Leaving bind-address on 127.0.0.1Grafana gets “connection refused” from the other VMBind to the internal IP and restrict port 3306 with the firewall
No $__timeFilter in the WHERE clauseEvery refresh scans the whole table and the time picker does nothingAlways filter time series and summaries with $__timeFilter(col)
No index on the time columnDashboards get slower as the table growsIndex created_at (or whatever column you filter on)
Mixing IST and UTC between writer and readerGraphs shifted 5.5 hours, “No data” for recent rangesStore UTC and set the data source session timezone to match the writer
Using Time series format for category totals“db has no time column” errorUse Format: Table for bar charts, pie charts and tables without a time axis

Interview questions

Click a question to open it. Each one is written the way an interviewer would ask it. Under it you will see why they ask it and one way you could answer, in your own words.

Fresher level

1. How would you connect Grafana to a production MySQL database safely?

Why they ask this: They want least privilege and network thinking, not only “add the data source”.

How you might answer: I create a dedicated user like grafana_ro with SELECT only on the needed database and limit it to the Grafana server’s IP. MySQL listens only on the internal address and the firewall allows port 3306 from Grafana alone.

If the database is busy I point Grafana at a read replica, and I make sure the time columns used in dashboards are indexed.

2. What does $__timeFilter do in a Grafana SQL query?

Why they ask this: A basic macro check.

How you might answer: It expands into a condition on the column I pass, like created_at BETWEEN FROM_UNIXTIME(start) AND FROM_UNIXTIME(end), using the dashboard’s time range. That way the query only reads rows in the visible range and follows the time picker.

2–5 years experience

3. A MySQL time series panel says “db has no time column”. Walk me through fixing it.

Why they ask this: They want you to know the format rules for SQL time series.

How you might answer: With Format set to Time series, Grafana needs a column named time holding a datetime or epoch seconds, sorted by time. Usually someone used $__timeGroup with their own alias, so I switch to $__timeGroupAlias or add AS “time”.

If the query has no time axis at all, like revenue by category, I set Format to Table and use a bar chart or pie chart instead.

4. Your MySQL graphs are shifted by five and a half hours. How do you find the cause?

Why they ask this: They’re checking that you understand DATETIME, session time zones and Grafana’s UTC handling.

How you might answer: I check how the application writes the column, NOW() on a server in IST or UTC_TIMESTAMP(), and compare SELECT MAX(created_at) with UTC_TIMESTAMP(). Then I look at the data source’s Session timezone, because FROM_UNIXTIME uses the session zone to build the filter.

The fix is to make the session zone match the writer, ideally UTC everywhere, and let the dashboard display Browser Time for the viewers.

Senior level

5. A business team wants live sales dashboards straight from the production orders database. What do you advise?

Why they ask this: They want trade-offs: load, safety, freshness and alternatives.

How you might answer: I’d start with a read replica and a read-only user so dashboards can’t affect checkout, plus indexes on the time columns and sensible refresh intervals. For heavy summaries I’d add small summary tables or materialised rollups refreshed every few minutes, so panels read hundreds of rows instead of millions.

I’d also add query caching or longer intervals for wall screens, and a data freshness panel so people trust what they see.

6. When would you choose a SQL data source in Grafana over exporting the same numbers to Prometheus?

Why they ask this: A design judgement question.

How you might answer: SQL is right when the truth already lives in the database and people want flexible slicing, like revenue by city and product for any date range, or tables of recent records. Prometheus is better for high-frequency operational numbers, alerting with labels and long series of counters.

Often I use both: an exporter or the app publishes order rates and error counts to Prometheus for alerting, and SQL panels answer business questions on the same dashboard.

Quick quiz

Q1. What does $__timeGroupAlias(created_at, 1h) add compared with $__timeGroup?

  1. A WHERE clause
  2. The alias AS “time”
  3. An ORDER BY
  4. A LIMIT
Show answer

B — The Alias version names the bucket column time, which time series format needs.

Q2. Which privilege should grafana_ro have on shopdb?

  1. ALL PRIVILEGES
  2. SELECT only
  3. SELECT and DELETE
  4. SUPER
Show answer

B — Grafana only reads, so SELECT is enough.

Q3. MySQL error 1045 when testing the data source means:

  1. The database does not exist
  2. No account matches that user, host and password
  3. The query is too slow
  4. The time column is missing
Show answer

B — 1045 is Access denied for user.

Q4. A pie chart of orders by status should use which format?

  1. Time series
  2. Table
  3. Logs
  4. Trace
Show answer

B — Category totals have no time axis, so Table format is right.

Q5. Rows are written with UTC_TIMESTAMP(). What should the data source Session timezone be?

  1. +05:30
  2. +00:00
  3. Empty always
  4. The browser time zone
Show answer

B — Match the zone the writer used; here that is UTC.

Quick recap

  • MySQL 8 on app01 listens on 192.168.56.71:3306, and only graf01 can connect, as grafana_ro with SELECT on shopdb.
  • The MySQL data source expands macros like $__timeFilter and $__timeGroupAlias into plain SQL using the dashboard time range.
  • Time series format needs a time column sorted by time; text columns become one series per value. Table format handles everything else.
  • Bar charts, pie charts and tables cover non-time-series business data; keep $__timeFilter so they follow the time picker.
  • Store times in UTC, set the session timezone to match the writer, and let dashboards display Browser Time.

Next lesson

Lesson 7: Logs in Grafana with Loki and Alloy → You install Loki on graf01 and Grafana Alloy on both VMs, ship system and application logs, and learn LogQL stream selectors, line filters and parsers in Explore.

Further reading: Grafana MySQL data source

Want to go deeper into Grafana MySQL data source? The Grafana docs for the MySQL data source and its query editor and macros list every option, and the MySQL manual pages on time zone support and the event scheduler explain the behaviour behind the Break It section. For the Linux side of running a database server, see the SaatVedha Linux administration course and Linux performance monitoring.

You can also see the full plan for this course on the Grafana Dashboards and Observability course page.

Scroll to Top

This website uses cookies

We use a few cookies to keep this site working. If you allow it, we also use Google Analytics to count visits so we can improve our lessons. You can accept all, deny the optional ones, or choose what to allow.