PostgreSQL panel returns no rows with epoch milliseconds
An SRE team monitors checkout latency with Grafana 11 and a PostgreSQL data source. The table checkout_requests has columns id, created_at (bigint, Unix epoch milliseconds), region (text), and duration_ms (integer). A panel query is:
SELECT created_at, duration_ms FROM checkout_requests WHERE $__timeFilter(created_at) ORDER BY created_at
The dashboard time range is Last 6 hours. The panel shows "No data", but running a similar query in psql returns about 40,000 rows for that period. The query inspector shows an error: operator does not exist: bigint >= timestamp with time zone. Other PostgreSQL panels using timestamp columns work. The team wants a fix that keeps the dashboard fast over 30 days and avoids changing the table schema before the next release.
1. Why does the panel fail when the same table has rows in psql?
Read the worked answer
The failure is a type mismatch introduced by the macro, not missing data. $__timeFilter(created_at) does not pass the raw dashboard times unchanged; Grafana expands it into a comparison between the named column and timestamp values derived from the dashboard time range. Because created_at is a bigint holding epoch milliseconds, PostgreSQL tries to compare bigint with timestamp with time zone and raises the error shown in the query inspector. A hand-written psql query probably used numeric epoch bounds or omitted the time filter entirely, so it never hit that comparison.
The panel also needs a column that Grafana can recognize as the time field. Selecting the raw bigint does not give Grafana a timestamp, even if the filter were fixed. Confirm both issues in the query inspector: the expanded SQL and the returned frame fields. The fix must convert the stored value to a timestamp for display while filtering on the raw column for index use.
2. Write a corrected query that returns a usable time series and still uses the existing index on created_at.
Read the worked answer
Use epoch bounds on the raw column and convert only for the time field:
SELECT to_timestamp(created_at / 1000.0) AS time, duration_ms FROM checkout_requests WHERE created_at >= $__unixEpochFrom() * 1000 AND created_at < $__unixEpochTo() * 1000 ORDER BY created_at
The WHERE clause stays on created_at as a bigint, so a normal B-tree index on that column remains usable. $__unixEpochFrom() and $__unixEpochTo() expand to epoch seconds, so multiplying by 1000 matches the stored milliseconds. The to_timestamp conversion produces a timestamp that Grafana can use as the time column. Alias it as time and confirm the panel's time field is set correctly.
If the team prefers a single expression, $__timeFilter(to_timestamp(created_at / 1000.0)) can work for correctness, but it filters on an expression and is more likely to force a scan. For a 30-day view, the raw-column bounds are the safer default.
3. What performance risk remains over 30 days, and how would you validate the fix before rollout?
Read the worked answer
The corrected filter is index-friendly, but a 30-day range can still return millions of rows and make the browser and database work hard. The panel only needs a time series, so aggregate in SQL if the row count is large, for example grouping by a time bucket and computing average or p95 duration. Keep the same raw-column bounds in the aggregate query. Also set a sensible max data points value so Grafana does not request more points than the panel can display.
Validate with EXPLAIN against production-like data to confirm an index scan or bitmap index scan on created_at, not a sequential scan. Compare the query inspector's returned row count and panel render time against the old timestamp-based panels. Test the last 6 hours, 7 days, and 30 days separately, because a query that is fast at 6 hours can still time out at 30 days.