Skip to content
BytePatterns

Trace ID vs Span ID: Distributed Tracing Explained

7 min readBytePatterns

Trace ID vs span ID: one 16-byte id for the whole request, a fresh 8-byte id per unit of work, the traceparent header each hop rewrites, and logs joined by id.

Trace ID vs span ID in one line: the trace id names the whole request and never changes as it crosses services, while a span id names one unit of work inside it and is minted fresh at every hop. A third id, the parent span id, is the caller's span id and turns a pile of spans into a tree. This article covers the ids themselves: sizes, the header, what each hop copies and what it replaces, following the W3C Trace Context recommendation as of October 2026.

The three ids side by side:

IdentifierSize in traceparentLifetime
Trace id16 bytes, 32 hex charactersminted once at the root, copied by every hop
Span id8 bytes, 16 hex charactersnew for every unit of work
Parent span id8 bytes, 16 hex charactersthe caller's span id; absent on the root

The problem it solves

A checkout request touches a gateway, an orders service and a payments service. Each writes good logs, and none can say which records belong to the same request once thousands overlap. Two questions need two answers:

  • Which records belong to this request? One label shared by everything it touched: the trace id.
  • Who called whom? A label per piece of work plus a pointer to the caller: the span id and the parent span id.

One id cannot do both jobs. Reuse the trace id at every hop and all spans look identical; mint a new trace id at every hop and the request falls apart into fragments.

The intuition

A parcel's tracking number is the trace id: printed once, read at every depot. Each depot scan is a span with its own scan number, and "arrived from depot 41" is the parent span id. The rules:

  1. The first hop has no incoming context, so it mints a trace id and its own span id. Its span is the root, with no parent.
  2. Every outbound call carries a traceparent header: version-traceid-parentid-flags, such as 00-, 32 hex, a dash, 16 hex, -01.
  3. The callee copies the trace id and flags, records the incoming parent field as its span's parent, and mints a fresh span id.
  4. Calling onward, it writes its own span id into the parent field, which always means "the span that made this call".

The flags byte carries the sampling decision; its lowest bit means "sampled". A companion tracestate header can carry vendor-specific data alongside, and an all-zero trace id or parent id is invalid.

Logs join traces through the same id. If every log line carries trace_id and span_id as fields, one search for a trace id returns every line the request produced, in every service. Collectors and sampling are covered in distributed tracing explained.

Watch it run

The animation draws one request as a waterfall, a lane per service. The gateway opens the root span and mints the trace id, and every box that follows carries it. Each call passes it on with the caller's span id, so search hangs under the gateway and the store call under search, three levels deep, on different machines. Read the boxes as ids: one trace id across the picture, a different span id in every box, every parent pointer aiming one level up. The last frames turn to sampling cost.

Tracing a Request

Step 1 of 10

A dashboard says the search page got slow. A trace says which hop did.

The same interactive animation as the lesson — step through it with the controls.

The code

Derived from the lesson's span model: a name, its own id and its parent's id, all sharing the trace id. First the ids and the header; real tracers use a random source like secrets, this page seeds random for reproducible output:

import random
import re
import secrets

print(len(secrets.token_hex(16)), len(secrets.token_hex(8)))   # 32 16

rng = random.Random(13)                    # seeded for this page; real tracers use a CSPRNG

def new_trace_id():
    return "%032x" % rng.getrandbits(128)  # 16 bytes, 32 lowercase hex chars

def new_span_id():
    return "%016x" % rng.getrandbits(64)   # 8 bytes, 16 lowercase hex chars

TRACEPARENT = re.compile(r"^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$")

def build(trace_id, span_id, sampled=True):
    return "00-%s-%s-%s" % (trace_id, span_id, "01" if sampled else "00")

def parse(header):
    """(trace_id, parent_id, sampled), or None if the header is missing or invalid."""
    m = TRACEPARENT.match(header or "")
    if not m:
        return None
    version, trace_id, parent_id, flags = m.groups()
    if version == "ff" or trace_id == "0" * 32 or parent_id == "0" * 16:
        return None
    return trace_id, parent_id, int(flags, 16) & 1 == 1

h = build(new_trace_id(), new_span_id())
print(h)          # 00-e8d79f49af6d114c4a6f188a424e617b-cd502d42af1ffe0d-01
print(parse(h))   # ('e8d79f49af6d114c4a6f188a424e617b', 'cd502d42af1ffe0d', True)
print(parse("00-" + "0" * 32 + "-" + "a" * 16 + "-01"), parse("00-ABC-123-01"))   # None None

Now three services. Each hop keeps the trace id, mints its own span id and puts it in the parent field of its calls. Orders calls payments twice, so two spans share a parent:

spans, logs = [], []

def handle(name, headers, calls=()):
    """One hop: join the incoming trace (or start one), mint a span id, call onward."""
    ctx = parse(headers.get("traceparent"))
    if ctx is None:                                   # no header: this hop is the root
        trace_id, parent_id, sampled = new_trace_id(), None, True
    else:                                             # copy trace id and flags as they came
        trace_id, parent_id, sampled = ctx
    span_id = new_span_id()                           # every unit of work gets a fresh one
    spans.append({"trace_id": trace_id, "span_id": span_id,
                  "parent_id": parent_id, "name": name})
    logs.append("trace_id=%s span_id=%s %s done" % (trace_id, span_id, name))
    out = {"traceparent": build(trace_id, span_id, sampled)}   # MY id in the parent field
    print("%-18s trace %s… parent %-16s span %s" % (name, trace_id[:8], parent_id or "-", span_id))
    for callee, more in calls:
        handle(callee, out, more)

checkout = [("orders /checkout", [("payments authorize", []), ("payments capture", [])])]
handle("gateway POST", {}, checkout)
# gateway POST       trace a6ea1c0d… parent -                span aa8b230f3b05e392
# orders /checkout   trace a6ea1c0d… parent aa8b230f3b05e392 span de85eb9025ac45a0
# payments authorize trace a6ea1c0d… parent de85eb9025ac45a0 span a415c4c839a44721
# payments capture   trace a6ea1c0d… parent de85eb9025ac45a0 span 2ff7c0fcbbe8f88d

The trace column never changes, the span column never repeats, and each parent is the span one level up. That is all the collector needs. Then a second request runs, and one search by trace id pulls the first request's lines out of the mixed log:

def rebuild(spans):
    """The collector: children by parent id, roots are spans whose parent is unknown."""
    ids = {s["span_id"] for s in spans}
    kids = {}
    for s in spans:
        kids.setdefault(s["parent_id"], []).append(s)
    roots = [s for s in spans if s["parent_id"] not in ids]
    return roots, kids

def draw(span, kids, depth=0):
    print("  " * depth + span["name"])
    for child in kids.get(span["span_id"], []):
        draw(child, kids, depth + 1)

roots, kids = rebuild(spans)
for root in roots:
    draw(root, kids)
# gateway POST
#   orders /checkout
#     payments authorize
#     payments capture
print(len({s["trace_id"] for s in spans}),                  # one trace id for the request
      len({s["span_id"] for s in spans}) == len(spans))     # no span id used twice
# 1 True

handle("gateway GET", {}, [("orders /history", [])])        # a second request, same services
# gateway GET        trace d885bbac… parent -                span bea4256e36c2a4c7
# orders /history    trace d885bbac… parent bea4256e36c2a4c7 span 07ac5fed4b6ea010
trace = spans[0]["trace_id"]
mine = [line for line in logs if "trace_id=" + trace in line]
print(len(logs), len(mine))                                 # 6 4
print([line.split()[2] for line in mine])                   # ['gateway', 'orders', 'payments', 'payments']

The seeded check: 300 random call trees mixed into one span pool. Grouping by trace id must give 300 traces, each rebuilding into one tree that reaches every span, with no span id repeated:

import contextlib
import io

def random_calls(r, depth=0):
    return [("svc%d" % r.randrange(5), random_calls(r, depth + 1) if depth < 3 else [])
            for _ in range(r.randint(0, 3 - depth))]

ok = True
spans.clear()
with contextlib.redirect_stdout(io.StringIO()):             # silence the per-hop prints
    for seed in range(300):
        handle("root", {}, random_calls(random.Random(seed)))
by_trace = {}
for s in spans:
    by_trace.setdefault(s["trace_id"], []).append(s)
for group in by_trace.values():
    roots, kids = rebuild(group)
    reached, stack = 0, list(roots)
    while stack:                                            # walk the rebuilt tree
        s = stack.pop()
        reached += 1
        stack += kids.get(s["span_id"], [])
    ok &= len(roots) == 1 and roots[0]["name"] == "root" and reached == len(group)
print(len(by_trace), len(spans), ok, len({s["span_id"] for s in spans}) == len(spans))
# 300 1389 True True

The complexity

  • Header: fixed size, 55 characters for version 00, whatever the depth of the call tree.
  • Per hop: one parse, one random 64-bit id, one string build, all constant time.
  • Collector: grouping by trace id and indexing children by parent id is linear in the number of spans.
  • Collisions: random 64-bit span ids only need to be unique within a trace; for a trace of a thousand spans, the chance of a repeat is roughly one in tens of trillions. The 128-bit trace id has to be unique across all traffic, which is why it is twice as long.

Where it goes wrong

  • Minting a new trace id at every hop. Each service owns a one-span trace, and nothing errors. Proxies, queues and hand-written HTTP clients are the usual culprits.
  • Reusing the incoming parent id as your own span id. Two spans share an id and the tree collapses.
  • A header that fails validation. Uppercase hex, a wrong length or all zeros make the callee start a new trace, silently.
  • Logs without the trace id. The trace shows where the time went; nobody finds the lines that say why.
  • Trusting ids from the public internet. A gateway can start a fresh trace for external requests.
  • Counters instead of random ids. Two instances starting at the same value collide at once.

When it shows up in interviews

As a follow-up to "how would you debug a slow request across microservices?" or "how do logs from different services get correlated?". Expect: what travels in the header, what each service changes, how the tree is rebuilt. Asynchronous hops are a favourite trap: a queued message must carry the context in its metadata, which matters for store-and-forward messaging. The wider picture is in observability explained, and the lesson is tracing a request.

How to say it in an interview

"The trace id identifies the whole request: the first service mints it, 16 random bytes, and every hop copies it unchanged. A span id identifies one unit of work and is minted fresh at every hop, 8 bytes. They travel in the W3C traceparent header; each service records the incoming parent id as its span's parent and writes its own span id into the header it sends on. The collector groups by trace id and links by parent id. And I log the trace id and span id on every line, so a slow span leads straight to its logs."

Frequently asked questions

What is the difference between a trace ID and a span ID?

A trace id names the entire request and is identical in every span it produces, across every service. A span id names one unit of work, such as one handler or one outbound call, and is unique within the trace. A span's parent id is its caller's span id, which is how the spans form a tree.

What is the traceparent header format?

Four dash-separated lowercase hex fields: a 2-character version, a 32-character trace id, a 16-character parent id and a 2-character flags field, such as 00- plus the ids plus -01. The parent id is the sender's own span id, and the lowest flag bit means the trace is sampled. All-zero ids are invalid.

How do I correlate logs with traces?

Write the current trace id and span id into every log line as structured fields. Searching for one trace id then returns every line the request produced across all services, and the span id narrows it to the work inside one span. Most tracing libraries can inject both fields into the logger automatically.

Sources