Databases

Migrate InfluxDB 2 to InfluxDB 3 on Ubuntu 26.04 / 24.04

InfluxDB 3 will not read an InfluxDB 2 data directory. There is no in-place upgrade, no conversion utility, and no shared storage engine. The move is an export followed by an import, and on a 30-day bucket holding 777,809 points that part took twelve seconds of wall time. Everything else is where the hours go.

Original content from computingforgeeks.com - post 171348

This guide covers how to migrate InfluxDB 2 to InfluxDB 3 Core on Ubuntu 26.04 and 24.04: exporting a bucket with influxd inspect export-lp, correcting the integer types the exporter silently gets wrong, importing the line protocol into an InfluxDB 3 database, then repointing Telegraf and Grafana. It also covers the two things that break quietly, which are Flux and the one-bucket time shift in every aggregate query you have written.

Benchmarked September 2026 on InfluxDB 3 Core 3.11.4 (Ubuntu 26.04).

Why this is suddenly urgent

On 15 September 2026 the latest tag for InfluxDB Docker images stops pointing at InfluxDB 2 and starts pointing at InfluxDB 3 Core, which InfluxData states in the InfluxDB 3 Core install documentation. A container that pulls influxdb:latest after that date comes up as a different database with a different storage engine, a different query language, and no knowledge of the volume it just mounted.

Whether the switch has already happened is checkable in one command, by comparing what latest resolves to against the pinned InfluxDB 2 tag:

for t in latest 2; do
  printf "%-7s " "$t"
  curl -s "https://hub.docker.com/v2/repositories/library/influxdb/tags/$t" | grep -o '"digest": *"[^"]*"' | head -1
done

Matching digests mean latest is still InfluxDB 2, which is what it printed on 6 September 2026:

latest  "digest":"sha256:54ccb17391b0964f30ba9451d8a869ff8c7beefe76103e636f2c4a8502154e5f"
2       "digest":"sha256:54ccb17391b0964f30ba9451d8a869ff8c7beefe76103e636f2c4a8502154e5f"

Once they diverge, the change has landed. Pin before that happens, and note that pulling an image pins nothing. The pin is the image reference in the compose file or the run command:

services:
  influxdb:
    image: influxdb:2        # stays on InfluxDB 2
    # image: influxdb:3-core # the InfluxDB 3 Core line

Package installs are not affected by the tag change, because influxdb2 and influxdb3-core are separate packages that install side by side. The deadline only matters for anyone tracking latest.

What changes when you migrate InfluxDB 2 to InfluxDB 3

InfluxDB 3 is a rewrite in Rust on Apache Arrow and Parquet. Concepts that look similar carry different names and different guarantees, and one of them is gone entirely.

ItemInfluxDB 2.9InfluxDB 3 Core 3.11
Default port80868181
Data containerBucket, inside an orgDatabase, no org
AuthOrg plus tokenToken only
StorageTSM plus WALParquet plus WAL
Query languagesFlux, InfluxQLSQL, InfluxQL
FluxSupportedEndpoint returns 404
TasksFlux tasksProcessing engine plugins
Health endpointOpenRequires a token
Data directory/var/lib/influxdb/var/lib/influxdb3

Flux is the one that ends careers. InfluxData describes it as being in maintenance mode and states plainly that it is not supported in InfluxDB 3 because the rewrite from Go to Rust could not carry it forward. Against InfluxDB 3 Core the Flux endpoint does not error politely, which is worth confirming once the server is up at Step 3:

curl -s -o /dev/null -w "%{http_code}\n" -X POST "http://127.0.0.1:8181/api/v2/query" \
  -H "Authorization: Bearer ${INFLUXDB3_AUTH_TOKEN}" \
  -H "Content-Type: application/vnd.flux" \
  --data-binary 'from(bucket: "system_metrics") |> range(start: -1h)'

The route does not exist at all, so every Flux dashboard, task and client library call fails the same way:

404

Count the Flux you own before starting. Every from(bucket:) pipeline has to become SQL or InfluxQL, and that rewrite is the largest single item in the migration.

Step 1: Confirm the versions and the engine path

Two tokens are used throughout this guide, one per version. Export them now so every later command is copy-paste ready. The InfluxDB 2 token is the one influx setup wrote to ~/.influxdbv2/configs; the InfluxDB 3 token does not exist yet and gets filled in at Step 3:

export INFLUX_V2_TOKEN=$(awk -F'"' '/^  token/{print $2; exit}' ~/.influxdbv2/configs)
export INFLUXDB3_AUTH_TOKEN='apiv3_your_token_here'

Both packages come from the same InfluxData apt repository. Add it if the host does not have it already:

sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://repos.influxdata.com/influxdata-archive.key | sudo gpg --dearmor -o /etc/apt/keyrings/influxdata-archive.gpg
echo "deb [signed-by=/etc/apt/keyrings/influxdata-archive.gpg] https://repos.influxdata.com/debian stable main" | sudo tee /etc/apt/sources.list.d/influxdata.list
sudo apt-get update

The repository uses a distribution-agnostic stable component, so Ubuntu 26.04 and 24.04 resolve to the same builds, and the RHEL family uses the same source with a .repo file instead. Both Ubuntu test hosts offered identical candidates:

apt-cache policy influxdb2 influxdb2-cli influxdb3-core telegraf | grep -E '^[a-z0-9]|Candidate'

Note the skew between the server and the CLI, which is normal and not a broken mirror:

influxdb2:
  Candidate: 2.9.1-1
influxdb2-cli:
  Candidate: 2.8.0-1
influxdb3-core:
  Candidate: 3.11.4-1
telegraf:
  Candidate: 1.39.3-1

The packaged CLI reports its own version as dev rather than 2.8.0, which is cosmetic. The engine path matters far more, and this is where the official export documentation misleads. It uses ~/.influxdbv2/engine, which is where a manually extracted tarball keeps its data. The Ubuntu package does not:

sudo find /var/lib/influxdb/engine -maxdepth 2 -type d

Data, WAL and the replication queue all live under /var/lib/influxdb/engine, and that is the path the export command needs:

/var/lib/influxdb/engine
/var/lib/influxdb/engine/replicationq
/var/lib/influxdb/engine/wal
/var/lib/influxdb/engine/data

Collect the bucket ID at the same time, because the exporter takes an ID and not a name:

influx bucket list

The first column is what Step 2 needs. Internal buckets do not require migrating:

ID			Name		Retention	Shard group duration
a30282e62450f0d9	_monitoring	168h0m0s	24h0m0s
854b699626bcf941	_tasks		72h0m0s		24h0m0s
605a5d58d67640bf	system_metrics	infinite	168h0m0s

Step 2: Export the bucket to line protocol

Stop the writers first. The exporter takes a point-in-time cut, so anything Telegraf sends after the command starts lands in InfluxDB 2 and never reaches the export file. On the test host, two exports 42 seconds apart differed by 675 lines, which is three Telegraf collection cycles at 225 lines each, stranded in the old database:

sudo systemctl stop telegraf

InfluxDB 2 itself can stay running. Contrary to a widespread assumption, influxd inspect export-lp reads the WAL as well as the TSM files, so it does not miss data that has not been compacted yet:

sudo influxd inspect export-lp \
  --bucket-id 605a5d58d67640bf \
  --engine-path /var/lib/influxdb/engine \
  --output-path /tmp/export.lp

The log lines report both sources separately. A fresh bucket that has never been compacted shows zero TSM files and still exports every point (the ts and caller fields are trimmed here for width):

{"level":"info","msg":"exporting TSM files","tsm_dir":"/var/lib/influxdb/engine/data/605a5d58d67640bf","file_count":0}
{"level":"info","msg":"exporting WAL files","wal_dir":"/var/lib/influxdb/engine/wal/605a5d58d67640bf","file_count":5}
{"level":"info","msg":"export complete"}

The output size is the first surprise. A bucket holding 777,809 points produced a 176.6 MiB file:

ls -l /tmp/export.lp
wc -l < /tmp/export.lp

Both figures land higher than a 777,809 point bucket suggests, and neither is a bug:

-rw-r--r-- 1 root root 185215556 Sep  5 22:41 /tmp/export.lp
2248814

Why the line count is roughly triple the point count

That is 2.9 lines for every point written. The exporter emits one line per field, not one line per point. InfluxDB 2 stores each field in its own TSM series, and the export walks those series rather than reassembling points. A single CPU sample comes back as six separate lines carrying identical tags and identical timestamps:

cpu,cpu=cpu-total,host=metrics01 usage_idle=99.4739478957915 1788647970000000000
cpu,cpu=cpu-total,host=metrics01 usage_nice=0 1788647970000000000
cpu,cpu=cpu-total,host=metrics01 usage_user=0.12525050100199667 1788647970000000000
cpu,cpu=cpu-total,host=metrics01 usage_irq=0 1788647970000000000
cpu,cpu=cpu-total,host=metrics01 usage_guest=0 1788647970000000000
cpu,cpu=cpu-total,host=metrics01 usage_softirq=0 1788647970000000000

InfluxDB 3 merges them back into one row per timestamp on write, so the row count after import is far lower than the line count. Anyone who compares exported lines against imported rows will conclude that most of the data vanished. It did not. Step 5 shows what to compare instead.

Budget for a much larger multiplier than the 2.9 above. That figure comes from a test bucket seeded with a synthetic backfill carrying three fields per point. Stock inputs.cpu writes ten fields per row and inputs.mem around thirty, so a bucket filled by a default Telegraf config expands closer to ten or fifteen times, not three. Check free space against the field count, not the point count.

Step 3: Install InfluxDB 3 Core beside InfluxDB 2

The two packages do not conflict. Installing influxdb3-core leaves influxdb2 in place, keeps both systemd units, and binds different ports, which is what makes a same-host cutover possible. On a host with nothing to carry over, installing the stack from scratch is the shorter path:

sudo apt-get install -y influxdb3-core

InfluxDB 3 Core binds 0.0.0.0:8181 by default and the first admin token can be minted by anyone who reaches the port before you do. Close that window before the service ever starts:

sudo vim /etc/influxdb3/influxdb3-core.conf

Append the bind directive to the existing settings:

http-bind="127.0.0.1:8181"

Start the service and claim the instance immediately:

sudo systemctl start influxdb3-core
influxdb3 create token --admin --host http://127.0.0.1:8181

Store the token where the rest of this guide can read it, then create the target database. Bucket names carry over unchanged, which keeps InfluxQL queries readable:

export INFLUXDB3_AUTH_TOKEN='apiv3_your_token_here'
influxdb3 create database system_metrics --host http://127.0.0.1:8181

One monitoring detail changes here and it will page somebody at 03:00 if it goes unnoticed. InfluxDB 2 answers /health without credentials; InfluxDB 3 does not. An unauthenticated probe against port 8181 returns a JSON error rather than a status, so every uptime check pointed at the old endpoint needs a bearer token added.

Step 4: Fix the integer types before importing anything

This is the step nobody warns about, and skipping it means Telegraf silently drops metrics for as long as it takes somebody to read the journal. Import the export file as it comes out of InfluxDB 2, restart Telegraf against InfluxDB 3, and the agent starts failing on every write cycle:

E! [agent] Error writing to outputs.influxdb_v3: failed to write metric to system_metrics (will be dropped: 400 Bad Request): partial write of line protocol occurred

Telegraf reports the HTTP status and nothing else. The server response carries the real reason, so post one collection cycle by hand to see it:

telegraf --config /etc/telegraf/telegraf.conf --test | sed 's/^> //' > /tmp/live.lp
curl -s -X POST "http://127.0.0.1:8181/api/v3/write_lp?db=system_metrics&precision=ns" \
  -H "Authorization: Bearer ${INFLUXDB3_AUTH_TOKEN}" --data-binary @/tmp/live.lp

The response names the column, the expected type and the type that arrived:

{"error":"partial write of line protocol occurred","data":[{"error_message":"invalid column type for column 'io_time', expected iox::column_type::field::integer, got iox::column_type::field::uinteger","line_number":2,"original_line":"diskio,host=metrics0"}]}

The whole diagnosis fits in one screen, from the Telegraf journal line to the two commands that prove where the mismatch comes from:

Terminal showing the InfluxDB 3 invalid column type error where a field expected signed integer but Telegraf sent unsigned

The cause is a rendering choice in the exporter. InfluxDB 2 stores unsigned integers, and influxd inspect export-lp writes every one of them with the signed suffix i instead of the unsigned suffix u. Across a 2,248,814 line export there was not a single unsigned value:

grep -c '=[0-9]\+u' /tmp/export.lp
telegraf --config /etc/telegraf/telegraf.conf --test | grep -o '=[0-9]\+u' | wc -l

Telegraf, writing the same fields live, produced 133 unsigned values in one collection cycle:

0
133

InfluxDB 3 fixes a column’s type on first write and never widens it, so whichever side writes first wins permanently. Import the signed export and live Telegraf is rejected. Let Telegraf define the schema first and the signed historical data is rejected in the opposite direction, with expected iox::column_type::field::uinteger, got iox::column_type::field::integer.

Telegraf ships one escape hatch for exactly this situation. The influxdb_v3 output carries a convert_uint_to_int option whose own sample config describes it as useful “if existing data exist as signed integers e.g. from previous versions of InfluxDB”. Setting it to true does stop the rejections, tested on the same host against an uncorrected import:

[[outputs.influxdb_v3]]
  convert_uint_to_int = true

Correcting the export file is still the better trade. The Telegraf option narrows every unsigned field to a signed 64-bit column permanently, for the life of the database and for every agent that writes to it, in order to match an artifact of the exporter. Fixing the file once keeps the types Telegraf actually produces and leaves no config to explain to whoever inherits the box. Use the option when the historical data is already loaded and dropping the database is not an option.

Rewriting the suffixes

Because the exporter writes one field per line, each line carries exactly one value, which makes the correction a single pass with no line protocol parser. The field list comes from Telegraf itself, so it always matches the plugins actually in use:

sudo vim /usr/local/bin/fix-unsigned-fields.py

The script samples one Telegraf collection cycle, records every measurement and field pair that comes back unsigned, then rewrites only those lines. It reads telegraf.d as well as the main config, because inputs defined only in the drop-in directory would otherwise never be sampled and their unsigned fields would never be corrected. It refuses to write anything if the sample comes back empty, which is the difference between a loud failure and a silent no-op that ships the wrong schema:

#!/usr/bin/env python3
"""Restore unsigned integer suffixes that influxd inspect export-lp drops."""
import re, subprocess, sys

TELEGRAF_CONF = "/etc/telegraf/telegraf.conf"
TELEGRAF_DIR = "/etc/telegraf/telegraf.d"
SEP = re.compile(r"(?<!\\) ")

if len(sys.argv) != 3:
    sys.exit(f"usage: {sys.argv[0]} <export.lp> <corrected.lp>")
src, dst = sys.argv[1], sys.argv[2]

probe = subprocess.run(
    ["telegraf", "--config", TELEGRAF_CONF, "--config-directory", TELEGRAF_DIR, "--test"],
    capture_output=True, text=True, timeout=180)
if probe.returncode != 0:
    sys.exit(f"telegraf --test failed ({probe.returncode}):\n{probe.stderr.strip()[:500]}")

unsigned = set()
for line in probe.stdout.splitlines():
    line = line[2:] if line.startswith("> ") else line
    parts = SEP.split(line)
    if len(parts) < 3:
        continue
    measurement = parts[0].split(",")[0]
    for key, _ in re.findall(r"([A-Za-z_][A-Za-z0-9_]*)=(-?\d+)u(?=,|$)", parts[1]):
        unsigned.add((measurement, key))

if not unsigned:
    sys.exit("no unsigned fields in the Telegraf sample; refusing to write an unchanged copy")

changed = total = 0
with open(src) as fi, open(dst, "w") as fo:
    for line in fi:
        total += 1
        parts = SEP.split(line.rstrip("\n"))
        if len(parts) == 3:
            measurement = parts[0].split(",")[0]
            key, _, value = parts[1].partition("=")
            if (measurement, key) in unsigned and value.endswith("i"):
                line = f"{parts[0]} {key}={value[:-1]}u {parts[2]}\n"
                changed += 1
        fo.write(line)

print(f"unsigned fields detected: {len(unsigned)}")
print(f"lines rewritten: {changed} of {total}")

Run it against the raw export and keep the corrected copy separate:

python3 /usr/local/bin/fix-unsigned-fields.py /tmp/export.lp /tmp/export-fixed.lp

On the test host it touched 23 percent of the file in 6.3 seconds, across five measurements:

unsigned fields detected: 55
lines rewritten: 519863 of 2248814

The 55 pairs were concentrated in mem (32 fields), diskio (11), disk (6), swap (5) and system (1). A deployment running database, container or network input plugins will find a different set, which is why the list is generated rather than hardcoded.

Step 5: Import and verify row counts

The CLI handles a large file without manual chunking. It streams the input in roughly 10 MB requests and reports throughput as it goes:

influxdb3 write \
  --database system_metrics \
  --file /tmp/export-fixed.lp \
  --precision ns \
  --host http://127.0.0.1:8181

176.6 MiB across 18 requests finished in 5.3 seconds, holding 33.7 MiB/s on a 4 vCPU virtual machine:

  5s 235ms: 18 requests (3.44 requests/sec), 2248814 lines (429548 lines/s), 176.64MiB (33.74MiB/s)

The --precision ns flag is not strictly required, because the CLI defaults to auto and reads the exporter’s nanosecond timestamps correctly without it. State it anyway, so the command documents its own assumption. A wrong value fails loudly rather than corrupting anything, which is the reassuring part (the per-line detail is trimmed here):

{"error":"partial write of line protocol occurred","data":[{"error_message":"timestamp, 1788647960000000000, out of range for precision: Second","line_number":1, ...}]}

Nothing is written when that happens, so a mistyped precision costs a retry and no data. That holds because the CLI does not send accept-partial; posting the same file straight at /api/v3/write_lp, where partial acceptance is the default, would land the valid lines and leave a half-imported database.

Verify by comparing row counts per measurement, not line counts. Query InfluxDB 2 through its InfluxQL endpoint and InfluxDB 3 through the CLI, bounding the InfluxDB 3 side at the moment of the export so live writes do not inflate it. Pick a field that is present on every point: count() skips nulls, so counting a sparse field undercounts badly. On the test bucket count(usage_user) on cpu returned 432,045 while count(usage_guest) returned 50. InfluxDB 3 also accepts count(1), which sidesteps the choice entirely:

curl -s -G "http://127.0.0.1:8086/query" \
  --data-urlencode "db=system_metrics" \
  --data-urlencode "q=SELECT count(usage_user) FROM cpu" \
  -H "Authorization: Token ${INFLUX_V2_TOKEN}"

influxdb3 query -d system_metrics -H http://127.0.0.1:8181 \
  "SELECT count(usage_user) AS n FROM cpu WHERE time <= to_timestamp_seconds(1788648075)"

Use to_timestamp_seconds() and not to_timestamp(). The unqualified function reads a bare integer as nanoseconds, so to_timestamp(1788648075) evaluates to 1970-01-01T00:00:01.788648075 and the comparison quietly returns zero rows instead of raising an error. That single character difference is the fastest way to convince yourself a perfectly good migration failed.

Across all nine measurements the counts matched exactly:

MeasurementInfluxDB 2 rowsInfluxDB 3 rows
cpu432,045432,045
disk172,833172,833
mem86,41086,410
net86,40086,400
diskio7777
swap1111
system1111
kernel1111
processes1111

777,809 rows, reconstructed from 2,248,814 exported lines. The ratio is the field-per-line expansion from Step 2 and it is the expected result, not data loss.

Terminal showing influxd inspect export-lp exporting an InfluxDB 2 bucket and influxdb3 write importing it, with matching row counts

If a re-import is needed, drop the database rather than writing over it, because the column types set by the first attempt survive everything else. The delete prompts for confirmation unless told otherwise, and --hard-delete now destroys the data immediately instead of leaving a recoverable soft delete. Together those two flags remove every safety net, so read the database name twice:

influxdb3 delete database system_metrics --host http://127.0.0.1:8181 --yes --hard-delete now

Step 6: Point Telegraf at InfluxDB 3

Telegraf 1.38 and later ship a native influxdb_v3 output. Almost every tutorial still online reaches for the influxdb_v2 plugin against the compatibility endpoint, which works but keeps a translation layer nobody needs. Agents on other hosts need the same output block, and the Telegraf packages on Debian carry the identical plugin set, though they also need http-bind widened past the loopback address from Step 3 and a firewall rule for port 8181. Write the token to the environment file the unit already reads:

sudo vim /etc/default/telegraf

One line, holding the InfluxDB 3 admin token:

INFLUX_TOKEN=apiv3_your_token_here

Retire the old output rather than editing it, so a rollback is a single move command:

sudo mv /etc/telegraf/telegraf.d/influxdb_v2.conf /root/influxdb_v2.conf.bak
sudo vim /etc/telegraf/telegraf.d/influxdb_v3.conf

There is no organization and no bucket, only a database:

[[outputs.influxdb_v3]]
  urls = ["http://127.0.0.1:8181"]
  token = "${INFLUX_TOKEN}"
  database = "system_metrics"

Restart and read the journal rather than trusting the exit status, because a Telegraf that cannot write still reports as active:

sudo systemctl restart telegraf
sudo journalctl -u telegraf --since "-60s" --no-pager | grep -E 'E!|Loaded outputs'

A clean cutover shows the plugin loading and nothing else. Two warnings are expected on 1.39 and are not migration problems:

I! Loaded outputs: influxdb_v3
W! Strict environment variable handling is the new default starting with v1.38.0!
W! [agent] The default value of 'skip_processors_after_aggregators' will change to 'true' with Telegraf v1.40.0!

Any E! line mentioning column types means Step 4 was skipped or incomplete, and the metrics produced since the restart are already gone. Telegraf drops rejected batches rather than retrying them.

Step 7: Move Grafana across and rewrite the queries

Add the InfluxDB 3 connection as a second data source rather than editing the existing one. Keeping both live means panels can be converted one at a time and compared against the original while InfluxDB 2 still holds the same data. If Grafana is not yet on the host, the Grafana installation steps for Ubuntu 26.04 cover the repository setup.

Grafana data sources page listing an InfluxDB 2 Flux connection on port 8086 and an InfluxDB 3 SQL connection on port 8181

The new connection uses the same InfluxDB plugin with a different query language. Set Query language to SQL, the URL to port 8181, and fill the InfluxDB Details section: Database takes the database name, Token takes the admin token, and Insecure Connection has to be enabled for a server without TLS. Grafana queries InfluxDB 3 over FlightSQL on gRPC rather than over the HTTP API, and it attempts a TLS handshake unless that toggle is on:

flightsql: rpc error: code = Unavailable desc = connection error: desc = "transport:
authentication handshake failed: tls: first record does not look like a TLS handshake"

Grafana caches data source instances for a short period, so a panel can keep working for up to a minute after the setting is changed. Wait before concluding that the toggle made no difference.

Grafana InfluxDB data source settings showing the database field, configured token and Insecure Connection toggle for InfluxDB 3

Panel queries need two changes beyond the language itself. The first is the time macro, and it has a trap that costs an afternoon. $__timeFilter takes a column as an argument. Written bare it is not expanded at all, and the panel fails with a flat bad request that names nothing:

WHERE cpu = 'cpu-total' AND $__timeFilter          -- 400 bad request
WHERE cpu = 'cpu-total' AND $__timeFilter(time)    -- correct

Grafana expands the second form into explicit bounds before sending it, which is visible in the panel inspector:

WHERE cpu='cpu-total' AND time >= '2026-08-06T23:42:53Z' AND time <= '2026-09-05T23:42:53Z'

Either the macro with its column argument or the two bounds written out by hand will work:

SELECT date_bin(INTERVAL '30 minutes', time) AS time,
       avg(usage_user) AS "CPU user %"
FROM cpu
WHERE cpu = 'cpu-total'
  AND $__timeFilter(time)
GROUP BY 1
ORDER BY 1

The fixed 30-minute bin is worth revisiting once the panel works. It ignores the selected range, so zooming into an hour still buckets by 30 minutes. Grafana’s $__dateBin(time) macro applies date_bin using $__interval, which Grafana derives from the selected time range together with the panel width, expanding to a full call with an explicit origin:

date_bin(interval '1800 second', time, timestamp '1970-01-01T00:00:00Z')

Grafana’s macro table also lists $__timeGroup for the same job, but it did not survive testing on 13.2.1 in any form tried. With one argument it is left unexpanded and the panel returns the same flat bad request. With an interval argument it expands into SQL that swallows the tokens after it, and InfluxDB replies Schema error: No field named as. Use $__dateBin.

The second change is subtler and will not raise an error anywhere. Flux and SQL disagree about which edge of a window carries its label. Take the same hourly average of CPU user time over one fixed three-hour range, 16:00Z to 19:00Z, computed both ways.

Flux, using aggregateWindow, labels each bucket with its stop time:

2026-09-05T17:00:00Z    7.005325
2026-09-05T18:00:00Z    4.265908333333331
2026-09-05T19:00:00Z    8.993341666666671

SQL, using date_bin over the identical range, labels the same buckets with their start time:

2026-09-05T16:00:00     7.005324999999999
2026-09-05T17:00:00     4.265908333333334
2026-09-05T18:00:00     8.993341666666664

Same three averages down to floating-point noise, every one of them labelled an hour earlier. Graph shapes look unchanged, so this passes visual review, and then an alert rule that fires on a specific window boundary disagrees with its own history. InfluxQL on InfluxDB 3 uses start-edge labelling as well, so the shift appears no matter which of the two supported languages you migrate to. Adjust any threshold or annotation that was calibrated against Flux output.

With the queries converted, 30 days of history migrated from InfluxDB 2 renders from InfluxDB 3 with no gaps except the cutover window itself:

Grafana dashboard rendering thirty days of CPU and memory history migrated from InfluxDB 2 into InfluxDB 3 Core

The gap in the lower right panel is the eleven minutes between the export and the Telegraf restart. That interval is the real downtime of this migration and it is worth measuring on a staging host first, because it scales with the export and import rather than with the size of the bucket.

Where the data sits afterwards

Disk usage immediately after an import looks alarming and the explanation is worth understanding before anyone files a bug. InfluxDB 3 Core writes incoming data to its WAL and only converts it to Parquet on a snapshot. InfluxData’s durability page describes this as happening every ten minutes, which is the gen1-duration default and describes a server taking a steady stream of writes. The trigger that actually fires is the one in the Core configuration reference: wal-flush-interval at one second and wal-files-per-snapshot at 600. A WAL file is only written when there is something to flush, so on a host where Telegraf writes once every ten seconds those 600 files take closer to a hundred minutes than to ten.

Measured on the test host once InfluxDB 2 had finished compacting its own data:

StoreOn diskFiles
InfluxDB 2, compacted11 MB5 TSM, 32 KB WAL
InfluxDB 3, freshly imported340 MB0 Parquet, 127 WAL

Size the filesystem for the uncompressed intermediate state, not for the source database. A host with 200 MB free will fail a migration that only needed 11 MB of steady-state storage.

Resist the temptation to force the issue by lowering wal-files-per-snapshot. Setting it to 10 on the test host produced 17,309 Parquet files and grew the directory to 583 MB, and then queries over the full range stopped working entirely:

Query would scan 432 Parquet files, exceeding the file limit. InfluxDB 3 Core caps file
access to prevent performance degradation and memory issues. Use a narrower time range,
or increase the limit with --query-file-limit (this may cause slower queries or instability).

InfluxDB 3 Core has no compactor, so fragmentation it creates is fragmentation it keeps. Leave the snapshot settings alone and let the defaults produce a small number of large files.

What the migration actually cost

Wall-clock figures from a 4 vCPU, 8 GB virtual machine. The bucket held 777,809 rows across nine measurements: 30 days of synthetic history seeded into cpu, disk, mem and net at a 30-second interval, plus live Telegraf collection on top, which is why swap, system, kernel and processes carry only a handful of rows each in the table above.

PhaseTimeThroughput
Export to line protocol0.6 s176.6 MiB, 2,248,814 lines
Rewrite unsigned suffixes6.3 s519,863 lines changed
Import into InfluxDB 35.3 s429,548 lines/s, 33.7 MiB/s
Row-count verification5.0 s18 queries, 9 measurements
Observed write gap11 minTelegraf stopped to restarted

The data movement is twelve seconds and the verification another five. The eleven minutes came from working out the type mismatch in the middle of the cutover, which is exactly the part that does not need to happen twice. Export to a file, correct it, import into a throwaway database, confirm the counts, and only then stop Telegraf. Done that way the write gap is the length of one systemctl restart.

Scale the numbers by data volume rather than by retention. A year of the same collection interval is roughly twelve times the file size and twelve times the import time, and the type-correction pass stays linear. What does not scale is the query surface: an InfluxDB 3 Core instance is tuned for recent data, and splitting a multi-year bucket by year runs into Core’s ceiling of five databases, so anything past five years of history wants the free home-use tier of InfluxDB 3 Enterprise for its compaction instead. Where the historical data is the point rather than a byproduct, a VictoriaMetrics deployment is also worth pricing before committing.

Keep InfluxDB 2 installed and stopped for a week after the cutover. It costs 11 MB, it holds the only copy of anything the export missed, and rolling back is two systemctl commands plus moving the Telegraf output file back. Once the retention window has rolled past the migration date, remove it. Anyone standing up a comparison instance in the meantime can still follow the InfluxDB 2 setup on Ubuntu, which stays valid until the maintenance window closes.

Keep reading

Upgrade Ubuntu 24.04 to Ubuntu 26.04 LTS (Step by Step) Ubuntu Upgrade Ubuntu 24.04 to Ubuntu 26.04 LTS (Step by Step) UFW Firewall Commands with Examples on Ubuntu 24.04 / 22.04 Security UFW Firewall Commands with Examples on Ubuntu 24.04 / 22.04 Backup and Restore Linux Systems with Timeshift Debian Backup and Restore Linux Systems with Timeshift Get Started with LanceDB in Python AI Get Started with LanceDB in Python Qdrant vs Weaviate vs Milvus vs pgvector Benchmarked AI Qdrant vs Weaviate vs Milvus vs pgvector Benchmarked Install Kamailio SIP Server on Ubuntu 24.04 VOIP Install Kamailio SIP Server on Ubuntu 24.04

Leave a Comment

Press ESC to close