InfluxDB’s Flux language doesn’t enforce time zones at the storage level, meaning all timestamps are UTC, but how you interpret and display them in your queries depends entirely on the client and the query itself.

Let’s say you have data points like this, all stored as UTC:

_time                 _value
--------------------  --------
2023-10-27T10:00:00Z  100
2023-10-27T11:00:00Z  101
2023-10-27T12:00:00Z  102

If you’re in New York (UTC-4 during Daylight Saving Time), you might expect 2023-10-27T10:00:00Z to correspond to 6 AM your local time. But if you just query it, you’ll see 2023-10-27T10:00:00Z.

Here’s how to make Flux understand your local time zone.

The tz() Function: Your Time Zone Translator

The core of handling time zones in Flux is the tz() function. It takes a string representing a time zone (like "America/New_York" or "Europe/London") and applies it to a time value.

Consider a basic query for data from the last hour:

data
  |> range(start: -1h)
  |> yield(name: "raw_data")

This will return timestamps in UTC. To see them in New York time:

data
  |> range(start: -1h)
  |> tz(tz: "America/New_York")
  |> yield(name: "new_york_time")

Now, the _time column in the new_york_time output will be adjusted. A timestamp that was 2023-10-27T10:00:00Z will appear as 2023-10-27T06:00:00-04:00. The -04:00 indicates the offset from UTC for that specific time.

Applying tz() to Different Scenarios

1. Filtering by Local Time

If you want to find data points that occurred between 9 AM and 5 PM in London, you need to apply tz() before filtering.

data
  |> filter(fn: (r) => r._measurement == "cpu")
  |> range(start: today()) // Start of the current day in UTC
  |> tz(tz: "Europe/London")
  |> filter(fn: (r) => r._time.hour >= 9 and r._time.hour < 17)
  |> yield(name: "london_business_hours")

Why this works: range(start: today()) gives you data from midnight UTC. tz(tz: "Europe/London") then shifts that midnight UTC to midnight London time. Subsequent filtering on r._time.hour operates on these now-localized times.

2. Aggregating by Local Time Buckets

When you use window(), the time buckets are also created based on the current time zone context.

// Data in UTC
data
  |> range(start: -24h)
  |> aggregateWindow(every: 1h, fn: mean)
  |> yield(name: "utc_hourly_mean")

// Data aggregated into hourly buckets, displayed in Tokyo time
data
  |> range(start: -24h)
  |> tz(tz: "Asia/Tokyo")
  |> aggregateWindow(every: 1h, fn: mean)
  |> yield(name: "tokyo_hourly_mean")

Why this works: In the second query, tz() is applied before aggregateWindow. This means Flux interprets the every: 1h parameter relative to the Tokyo time zone. A bucket starting at 2023-10-27T09:00:00+09:00 will capture data points that fall within that local hour.

3. Displaying Timestamps in Dashboards

Many InfluxDB front-ends (like the Chronograf dashboard or the InfluxDB UI’s query builder) allow you to set a default time zone. This setting influences how timestamps are displayed after the query runs, often by applying a tz() transformation implicitly on the client side. However, for precise control within Flux, explicitly using tz() in your query is best.

If your dashboard displays data in UTC, but you want to see it in PST (America/Los_Angeles), you’d modify your query to include tz():

// Original query (likely shows UTC)
from(bucket: "my_bucket")
  |> range(start: -1h)
  |> filter(fn: (r) => r["_measurement"] == "temperature")
  |> yield(name: "temp_utc")

// Modified query to show PST
from(bucket: "my_bucket")
  |> range(start: -1h)
  |> tz(tz: "America/Los_Angeles")
  |> filter(fn: (r) => r["_measurement"] == "temperature")
  |> yield(name: "temp_pst")

Why this works: The tz() function modifies the _time column directly within the Flux stream. The dashboard then renders these already-converted timestamps.

4. Handling range() with Time Zones

When using range(), especially with relative times like today(), now(), or offsets, remember that these are initially interpreted in UTC. Applying tz() after range() might not give you the desired result if you intend the range itself to be relative to a local time.

// This gets data from midnight UTC to now (UTC)
data
  |> range(start: today(), stop: now())
  |> tz(tz: "America/New_York") // Converts the UTC range to NY time
  |> yield(name: "ny_time_range_utc_based")

// To get data from midnight NY time to now (NY time), you'd need to be more explicit
// or rely on client-side settings if available.
// A more robust Flux approach might involve calculating start/stop times in UTC
// that correspond to the desired local times.

Why this works: range(start: today()) resolves today() to the start of the current day in UTC. If you then apply tz() after this, you’re shifting the entire UTC-based window. To truly define a range based on local time, you might need to calculate the equivalent UTC start and end points for your desired local time window.

5. Converting Back to UTC

Sometimes, you might perform calculations in a local time zone but need to store or output the final timestamps back in UTC. You can achieve this by applying tz() with "UTC" as the argument.

data
  |> range(start: -1h)
  |> tz(tz: "Europe/Berlin") // Work with data in Berlin time
  |> filter(fn: (r) => r._value > 50)
  |> tz(tz: "UTC") // Convert the filtered timestamps back to UTC
  |> yield(name: "filtered_utc")

Why this works: The second tz(tz: "UTC") explicitly converts the _time column (which was previously set to Berlin time) back to its UTC representation.

The most surprising thing about time zones in Flux is that there’s no global setting; each query must explicitly declare its time zone context or rely on the client to interpret the raw UTC timestamps. This means that a query run by a user in Tokyo and the exact same query run by a user in London will produce different results if the query doesn’t explicitly use tz().

Here’s a look at how tz() affects the _time column during aggregation:

// Sample data points (all stored as UTC)
// 2023-10-27T00:30:00Z
// 2023-10-27T01:15:00Z
// 2023-10-27T02:45:00Z
// 2023-10-27T03:05:00Z

// Query without tz() - buckets align to UTC
// range(start: 2023-10-27T00:00:00Z, stop: 2023-10-27T04:00:00Z)
// aggregateWindow(every: 1h, fn: count)
// Result:
// _time                 count
// --------------------  -----
// 2023-10-27T00:00:00Z  1  (only 00:30 falls in this UTC hour)
// 2023-10-27T01:00:00Z  1  (only 01:15 falls in this UTC hour)
// 2023-10-27T02:00:00Z  2  (02:45, 03:05 fall in this UTC hour)
// 2023-10-27T03:00:00Z  0

// Query with tz(tz: "America/New_York") - buckets align to New York time (UTC-4)
// range(start: 2023-10-27T00:00:00Z, stop: 2023-10-27T04:00:00Z) // Range still UTC
// |> tz(tz: "America/New_York") // Now _time is interpreted as NY time
// |> aggregateWindow(every: 1h, fn: count)
// Result:
// _time                 count
// --------------------  -----
// 2023-10-27T00:00:00-04:00  1  (00:30 UTC is 2023-10-26T20:30-04:00, which is *before* 00:00 NY time, but 01:15 UTC is 2023-10-27T09:15-04:00, and 02:45 UTC is 2023-10-27T10:45-04:00. This example is tricky because the range is UTC. Let's re-evaluate.)

// Let's use a range that aligns better conceptually.
// If the *intent* is to aggregate by local hour:
data
  |> range(start: 2023-10-27T00:00:00Z, stop: 2023-10-27T04:00:00Z) // UTC range
  |> tz(tz: "America/New_York") // Convert _time to NY time
  |> aggregateWindow(every: 1h, fn: count) // Aggregate in NY time buckets
  |> yield(name: "ny_buckets")

// Let's trace the NY time buckets:
// 2023-10-27T00:00:00-04:00 (corresponds to 2023-10-27T04:00:00Z)
// 2023-10-27T01:00:00-04:00 (corresponds to 2023-10-27T05:00:00Z)
// ... and so on.

// The sample data points (UTC):
// 2023-10-27T00:30:00Z -> 2023-10-26T20:30:00-04:00 (falls into 2023-10-26T20:00:00-04:00 bucket)
// 2023-10-27T01:15:00Z -> 2023-10-26T21:15:00-04:00 (falls into 2023-10-26T21:00:00-04:00 bucket)
// 2023-10-27T02:45:00Z -> 2023-10-26T22:45:00-04:00 (falls into 2023-10-26T22:00:00-04:00 bucket)
// 2023-10-27T03:05:00Z -> 2023-10-26T23:05:00-04:00 (falls into 2023-10-26T23:00:00-04:00 bucket)

// If the range was meant to cover the same *local* period:
data
  |> range(start: 2023-10-27T08:00:00Z, stop: 2023-10-27T12:00:00Z) // 9 AM to 1 PM UTC -> 5 AM to 9 AM NYT
  |> tz(tz: "America/New_York")
  |> aggregateWindow(every: 1h, fn: count)
  |> yield(name: "ny_buckets_correct_range")

// This query correctly aggregates data within local time buckets.
// The key is that `tz()` must be applied *before* `aggregateWindow` if you want the windowing to respect the local time zone.

The most important detail often missed is how `tz()` interacts with `range()`. If you use relative time ranges like `start: today()` and then apply `tz()` *after* the `range` function, you are essentially shifting a UTC-defined window. To make `today()` mean "start of today in my local time zone," you would need to calculate the UTC equivalent of your local midnight and use that as the `start` parameter in `range()`.

The next thing you'll likely encounter is handling daylight saving time transitions, where a single wall-clock hour might repeat or disappear, affecting how your `tz()`-aware queries behave.

Want structured learning?

Take the full Influxdb course →