---
title: 'Detached HEAD State'
source: 'https://academia.sh/en/courses/branching-and-collaboration/detached-head-state'
course: 'Branching and Collaboration'
language: en
updated: '2026-08-17T18:10:43+00:00'
license: 'CC BY-SA 4.0'
---

# Detached HEAD State

When two commits made on a detached HEAD are reachable from no reference, history drops from 15 records to 13 and answers 4 of the six questions instead of 5; the real cost is that all three counting queries are answered incompletely, and history does not report it.

The previous three lessons built a three-link chain between `HEAD` and a commit: `HEAD`
points to a branch name, the branch name points to a commit ID, the commit points to
its own parent. Working on a branch depends on this chain being complete — when a
commit is made, the middle link advances, `HEAD` stays in place, and the new commit
ends up bound to a name.

The middle link is not always in place. The tool recognizes a state where `HEAD` points
directly to a commit ID, and this is called **detached HEAD**. This lesson measures how
that state arises, where a commit made there attaches, and what history loses when a
link of the chain is missing. The measurement's real result is not the amount lost; it
is that history does not **know** it lost anything.

## How HEAD Detaches

The detached state is not a defect or corruption; it is the tool's way of satisfying a
specific request. The request is: stand not at a branch's tip, but at a particular point
in history. When you switch to a commit ID, a tag, or a remote branch tip, `HEAD`
cannot bind to a name, because the target is not a name.

```text
# taught command and example dump — not executed

$ git switch --detach 7c3f9d2
HEAD is now at 7c3f9d2 Format report heading
$ git status --short --branch
## HEAD (no branch)
$ git symbolic-ref HEAD
fatal: ref HEAD is not a symbolic ref
```

The last line is the definition of the state. In the first lesson, the same command
answered `refs/heads/main`: `HEAD` was pointing to a reference. Here there is no
reference to point to, so the command cannot find a symbolic reference. `git status`
says the same thing in different words.

The state can be chosen deliberately. Moving back and forth through history while
searching for which commit a bug entered on, building and testing an old version,
inspecting the tree a tag points to — all of these are done in the detached state, and
none of them is a problem. The problem starts with **committing** in the detached
state.

## A Commit Made in the Detached State

Committing in the detached state is not blocked. The commit object is written, its
parent is correctly linked, its tree is recorded; in terms of content, it is no
different from any other commit. The only thing that changes is who advances: `HEAD`
points directly to the new ID, and **no branch reference is written**.

The consequence is not yet visible at the second commit, because the `HEAD` chain still
holds those commits. The consequence is born the moment you switch to a different
branch. `HEAD` now points to a different branch, and the commits written in the
detached state are reachable from no reference. The tool reports this at the moment of
switching.

```text
# taught command and example dump — not executed

$ git switch report
Warning: you are leaving 2 commits behind, not connected to
any of your branches:

  b71c05e report: move threshold table into config
  a04e9d2 report: add summary line

If you want to keep them by creating a new branch, this may be a good time
to do so with:

 git branch <new-branch-name> b71c05e

Switched to branch 'report'
```

The warning gives both the count and the ID, and it also states the fix: write a
branch. The first lesson's measurement gave the cost of this suggestion — a branch
reference is **41 bytes**. Binding two commits to history is writing forty-one bytes,
and this is why the way out is cheap.

There are three ways out of the detached state. `git switch -c name` writes a branch at
the point you are standing on and switches to it; this is the way that rescues the work
in place. `git switch <branch>` returns to a branch and leaves the detached commits
behind. The third is retroactive: a reference can also be written afterward with `git
branch name <id>` — as long as the ID is still known.

## The Reflog

The way to know the ID is the **reflog**. The tool writes **every value** `HEAD` and
every branch reference ever took to a local log: which commit was switched to, which
commit was made, which reference moved where. The log is local and does not travel with
the repository — a cloned repository does not get the source repository's reflog.

```text
# taught command and example dump — not executed

$ git reflog
b71c05e HEAD@{0}: commit: report: move threshold table into config
a04e9d2 HEAD@{1}: commit: report: add summary line
7c3f9d2 HEAD@{2}: checkout: moving from report to 7c3f9d2

$ git branch rescue b71c05e
$ git branch --contains b71c05e
  rescue
```

The `HEAD@{0}` syntax names `HEAD`'s previous positions and can be used anywhere a
commit ID is expected. The third line records the detachment itself; the first two
record the two commits made in the detached state. After the branch is written, the
last command confirms the commit is now reachable from a reference.

The reflog itself is not a reference and does not enter the reachability computation.
It is a recovery tool, not a storage mechanism: entries have a limited lifetime and
expire after a configurable period; entries belonging to unreachable commits have a
shorter lifetime than the others.

## The Boundary That Cannot Be Recovered

Once a reflog entry expires, no record remains to give an unreachable commit's ID. The
object is still in the database, but it has no name and nowhere to be looked up.

One step past this is permanent. The tool has a maintenance command that collects
objects reachable from no reference; this command's pruning option **deletes
unreachable objects from the database**. This lesson does not write that call in
executable form, because its result is **irreversible**: a deleted commit's content
cannot be brought back from anywhere, and the reflog cannot restore a deleted object
either. The maintenance command can also run on its own as part of certain operations;
this is why leaving an unreachable commit for "I'll recover it later" is not safe.

The safe habit is one sentence: **write the reference first.** If work is going to be
done in the detached state, a branch is opened before starting; if it is noticed after
the work is done, a backup branch is written while the reflog is still fresh.
Recovery's entire cost is forty-one bytes, and as long as that byte count is not
written, the work is a single maintenance call away from gone.

The measurement's assumptions:

- **BR22** — The setup is the shared definition's twelve commits, and `development` is
  not changed. The lesson changes a single step of the setup: the `report` work's
  second and third commits were made on a detached HEAD and are not bound to a branch
  tip.
- **BR23** — Reachability starts from references. If a commit cannot be reached from
  any branch reference, that commit is outside history; sitting in the object database
  does not change this.
- **BR24** — The measurement uses a single integration format: merge commit. Its
  definition is taken from the shared definition and not changed; this lesson does not
  compare formats.
- **BR25** — The definition of the six questions is not changed, and they are asked
  only of the reachable history. A question cannot know about a commit it was never
  given.
- **BR26** — Three **counting queries** are also asked and compared against the
  oracle: the number of commits belonging to the `report` work, the number of commits
  that introduced the bug, the number of commits touching the shared file. The oracle
  knows the setup and gives the true count.
- **BR27** — The reflog is not a reference and does not enter the reachability
  computation; it only serves to find an unreachable commit's ID.
- **BR28** — Recovery is binding the dropped commits to a reference. The measurement
  builds the recovered history by putting the dropped ones back and ordering them by
  time; recovery changes neither the commits' content nor their order.
- **BR29** — Recovery's cost is a single branch reference: forty hex digits and a line
  ending, **41 bytes**. The first lesson's measure applies here exactly as it was.

## Measurement

```python
"""Detached HEAD: a commit reachable from no reference is outside history.

Part 1 - two commits made in the detached state drop out of history.
Part 2 - six questions asked of three histories: which question falls outside.
Part 3 - counting queries compared against the oracle: answered, but incompletely.
"""
SEED = 20260813
BRANCHES = ("metrics", "report", "identity")
FILES = {"metrics": "metrics.py", "report": "report.py", "identity": "identity.py"}
BUGGY = ("report", 2)
SHARED_FILE = "config.py"
DETACHED = ("report", (2, 3))     # these two commits were made on a detached HEAD
REF_BYTES = 40 + 1


def rng(seed):
    state = seed % 2147483646 + 1

    def draw(n):
        nonlocal state
        state = (state * 48271) % 2147483647
        return state % n
    return draw


def development():
    draw, record, time = rng(SEED), [], 0
    for step in range(1, 5):
        for branch in BRANCHES:
            time += 1 + draw(3)
            file = (SHARED_FILE if step == 3 and branch in ("metrics", "report")
                    else FILES[branch])
            record.append({"branch": branch, "step": step, "file": file,
                          "buggy": (branch, step) == BUGGY, "time": time})
    return record


def integrate(record, fmt):
    t = []
    if fmt == "merge commit":
        for branch in BRANCHES:
            for k in [x for x in record if x["branch"] == branch]:
                t.append({**k, "branch_record": branch, "time_kept": True})
            t.append({"branch": branch, "step": 0, "file": None, "buggy": False,
                      "time": max(x["time"] for x in record
                                   if x["branch"] == branch),
                      "branch_record": branch, "merge": True,
                      "time_kept": True})
    return t


def question1_grouping(t):
    return all(x.get("branch_record") for x in t if not x.get("merge"))


def question2_bug(t):
    buggy = [x for x in t if x["buggy"]]
    return len(buggy) == 1 and not buggy[0].get("squashed")


def question3_order(t):
    return all(x.get("time_kept") for x in t)


def question4_file(t):
    return all(isinstance(x["file"], str) or x.get("merge") for x in t)


def question5_integrity(t):
    spots = {}
    for i, x in enumerate(t):
        if x.get("merge"):
            continue
        spots.setdefault(x["branch"], []).append(i)
    return all(y[-1] - y[0] == len(y) - 1 for y in spots.values())


def question6_conflict(t):
    return any("chosen" in x for x in t)


QUESTIONS = (("feature grouping", question1_grouping), ("bug isolation", question2_bug),
             ("true order", question3_order), ("file trail", question4_file),
             ("branch integrity", question5_integrity), ("conflict decision", question6_conflict))


def reachable(record, dropped):
    branch, steps = dropped
    return [k for k in record if not (k["branch"] == branch and k["step"] in steps)]


def recover(remaining, dropped_list):
    """Commits found via the reflog are bound to a reference."""
    return sorted(remaining + dropped_list, key=lambda x: x["time"])


def query(group):
    """Three counting queries asked of history or the setup."""
    return {"commits belonging to report": sum(1 for x in group
                                               if x["branch"] == "report"
                                               and not x.get("merge")),
            "commits that introduced the bug": sum(1 for x in group if x["buggy"]),
            "commits touching config.py": sum(1 for x in group
                                              if x["file"] == SHARED_FILE)}


record = development()
remaining = reachable(record, DETACHED)
dropped = [k for k in record if k["branch"] == DETACHED[0] and k["step"] in DETACHED[1]]
HISTORIES = (("attached HEAD", record), ("detached HEAD", remaining),
             ("recovered", recover(remaining, dropped)))
T = {name: integrate(k, "merge commit") for name, k in HISTORIES}

print(f"setup: {len(record)} commits; {len(dropped)} commits made on a detached HEAD "
      f"are reachable from no reference")
print()
print(f"{'history':<16s}{'reachable commits':>18s}{'history entries':>17s}"
      f"{'unreachable':>13s}{'answered':>10s}")
for name, k in HISTORIES:
    print(f"  {name:<14s}{len(k):18d}{len(T[name]):17d}"
          f"{len(record) - len(k):13d}"
          f"{sum(q(T[name]) for _, q in QUESTIONS):10d}")
print()
HEADER = (("attached HEAD", 14), ("detached HEAD", 14), ("recovered", 12))
print(f"{'question':<20s}" + "".join(f"{a:>{w}s}" for a, w in HEADER))
for name, q in QUESTIONS:
    print(f"  {name:<18s}" + "".join(
        f"{('yes' if q(T[a]) else 'no'):>{w}s}" for a, w in HEADER))
print()
oracle = query(record)
detached_q, recovered_q = query(T["detached HEAD"]), query(T["recovered"])
print(f"{'counting query':<32s}{'oracle':>7s}{'detached HEAD':>14s}{'missing':>9s}"
      f"{'recovered':>11s}")
for s in oracle:
    print(f"  {s:<30s}{oracle[s]:7d}{detached_q[s]:14d}{oracle[s] - detached_q[s]:9d}"
          f"{recovered_q[s]:11d}")
print(f"\n{sum(1 for s in oracle if oracle[s] != detached_q[s])} of three queries are "
      f"answered incompletely; history does not report the shortfall")
print(f"recovery cost: to bind {len(dropped)} commits back, "
      f"1 reference, {REF_BYTES} bytes")
```

```
setup: 12 commits; 2 commits made on a detached HEAD are reachable from no reference

history          reachable commits  history entries  unreachable  answered
  attached HEAD                 12               15            0         5
  detached HEAD                 10               13            2         4
  recovered                     12               15            0         5

question             attached HEAD detached HEAD   recovered
  feature grouping             yes           yes         yes
  bug isolation                yes            no         yes
  true order                   yes           yes         yes
  file trail                   yes           yes         yes
  branch integrity             yes           yes         yes
  conflict decision             no            no          no

counting query                   oracle detached HEAD  missing  recovered
  commits belonging to report         4             2        2          4
  commits that introduced the bug      1             0        1          1
  commits touching config.py          2             1        1          2

3 of three queries are answered incompletely; history does not report the shortfall
recovery cost: to bind 2 commits back, 1 reference, 41 bytes
```

## Reading the Numbers

The top table gives the size of the loss. Reachable commits fall from **12** to **10**,
entries in history from **15** to **13**; unreachable is **2**. Answered questions drop
from **5** to **4**. In a twelve-commit set, the loss is **2/12**; in a six-question
set, **1/6** — both are within the sets' resolution and defensible.

The middle table says which question drops, and the answer is a single one: **bug
isolation**. The commit that introduced the bug was the `report` work's second, and it
was one of the commits made in the detached state. History no longer sees a buggy
commit; when the question "can the commit that introduced the bug be found on its own"
is asked, the answer is no. This is the **reported** face of the loss: history says it
cannot answer a question.

None of the other five rows changes, and this lesson's real result comes from here.
**Feature grouping** is yes in all three cases; all ten of the remaining commits carry
a branch record, and history is internally consistent. **File trail** is also yes in
all three; every remaining commit carries a file name. History is not aware that it is
incomplete, because incompleteness can only be seen with an outside measure.

The bottom table supplies that outside measure. All **3** of the three counting
queries are answered incompletely. The commit count belonging to the `report` work
comes out to **2** instead of **4** — the group is readable, but half of it is missing.
The commit that introduced the bug comes out to **0** instead of **1**. Commits
touching the shared file come out to **1** instead of **2** — the file trail question
says yes, but the trail it gives is half. No row carries an error message; all three
answers look reasonable, and all three are wrong.

This is the distinction between the two kinds of loss. When a question drops, history
says no, and the reader knows it. When a commit drops, history keeps saying yes, and
the reader is left with a wrong number. The **1/6** drop in the number of answered
questions is only the visible part of the real loss.

The last two lines measure recovery. When the two dropped commits are bound to a
reference, reachable commits return to **12**, history to **15**, answered questions to
**5**, and the three counting queries match the oracle exactly. Recovery's cost is
**41 bytes**. The first lesson's measure had also said the reverse: **writing** a
reference was forty-one bytes, so not writing that reference was a forty-one-byte
shortfall too. The whole story of the detached state is these unwritten forty-one
bytes.

## Summary

- Detached HEAD is the state where `HEAD` points directly to a commit ID instead of a
  branch name; `git symbolic-ref HEAD` fails, and the state is not a defect, it is a
  way of positioning.
- A commit made in the detached state is written normally, but no branch reference
  advances; once you switch to a different branch, those commits are reachable from no
  reference.
- **2** unreachable commits bring history down from **15** entries to **13** and drop
  answered questions from **5** to **4**; the single question that drops is bug
  isolation.
- The real cost is that all **3** of the three counting queries are answered
  incompletely: history keeps saying yes, and the numbers it gives are **2** instead of
  **4**, **0** instead of **1**, **1** instead of **2**.
- The reflog holds `HEAD`'s past positions and brings recovery down to **41 bytes**;
  once a log entry expires, object collection makes the loss permanent and
  irreversible.

## Next Step

This topic has finished the opening side of branching. Opening a branch is a name and
writes **123** bytes; keeping the name orderly adds to history's first question what
the record alone cannot give; never writing the name at all drops two commits outside
history. All three measurements look in the same direction: what ties a set of commits
to a piece of work is the reference.

What remains is the fork's other end. Three branches were opened, twelve commits were
written, and each stands at its own tip; at some point these tips have to be brought
into the main branch. The next topic asks what merging writes to history: does the tool
produce a new record when it closes the two tips, what is lost if it does not, and how
many of the six questions does the way of closing leave answerable. From here on, the
course runs on top of this measure.
