For many developers, Git branches remain slightly frightening — something you create nervously and merge with your fingers crossed. Much of that fear comes from imagining a branch as a full, separate copy of your project. It is not. A branch is a lightweight pointer, and internalising that makes the whole system far less intimidating.
Understanding what a branch really is changes how freely you use them.
What a commit and branch really are
In Git, your history is a series of commits, each a snapshot of your project linked to the one before. A branch is simply a movable pointer to one of those commits — a named label that advances as you add new commits. Creating a branch does not duplicate your files; it just creates another pointer, which is why it is nearly instant and cheap.
Seeing branches as labels on a graph of snapshots, rather than as copies, dissolves most of the mystery.
Why cheap branches change how you work
Because branches are so lightweight, you can create them freely — one per feature, experiment or fix — without cost. This lets you isolate work in progress from stable code, try ideas without fear, and throw away a branch that did not pan out with no harm done. The cheapness is the whole point: it encourages experimentation.
Work you are unsure about lives on its own branch, where it cannot destabilise anything until you decide it is ready.
Merging without dread
Merging is just Git reconciling the histories of two branches. Most of the time it combines them automatically; conflicts arise only when the same lines changed in both, and a conflict is a question for you to answer, not a disaster. Small, frequent merges keep those questions small.
Once branches are pointers and merges are reconciliations, Git stops feeling like a minefield and starts feeling like the safety net it is meant to be.
Fast-forward merges: when Git can skip creating a merge commit entirely
If the branch being merged into has not advanced at all since the feature branch was created — meaning the target branch's own pointer is simply an ancestor of the feature branch's tip — Git can perform a fast-forward merge, which does not create a new merge commit at all; it just moves the target branch's pointer forward to match the feature branch's tip directly, since every commit needed is already right there in a straight line. This is why some teams enforce `--no-ff` on merges specifically, forcing an explicit merge commit even when a fast-forward would otherwise be possible, purely to preserve a visible marker in history showing that a feature branch existed and was merged, rather than having its commits blend in indistinguishably with the direct line of history on the target branch.
Remote-tracking branches: a local pointer to where the remote branch was, not is
A remote-tracking branch like `origin/main` is itself just another movable pointer, but a locally-updated one specifically: it reflects the state of `main` on the remote as of the last time the local repository fetched, not the remote's current live state, which is exactly why `git fetch` can show new commits on `origin/main` that were not visible a moment ago — nothing changed on the actual remote at that exact moment, the local snapshot of it simply caught up. Confusing a remote-tracking branch with the actual remote branch it mirrors is a common source of confusion around why a colleague's just-pushed commit is not visible locally yet despite genuinely existing on the remote — a `git fetch` is needed first to update the local pointer before the corresponding change is visible at all.
Pruning stale branches: cleaning up pointers, not deleting work
A repository that has accumulated dozens of merged, long-dead feature branches is not accumulating any actual extra data of consequence — every commit on those branches almost certainly already exists on the target branch it was merged into — it is accumulating pointer clutter, which is exactly why running `git branch --merged` to list branches already fully merged, and deleting them, is a purely administrative cleanup with essentially zero risk to any actual work: the commits those branches pointed at remain fully present on the branch they were merged into, and deleting the now-redundant pointer does not touch them at all.
Why a lightweight branch, not a heavy one, made feature-branch workflows practical at all
Git's branches being nearly free to create — a new pointer costs a fraction of a kilobyte on disk, regardless of how large the actual repository history behind it is — is precisely what made the now-ubiquitous convention of creating a new branch for every single feature, bugfix, or experiment practical in the first place; a version control system where branching genuinely meant copying the entire project, the mental model many newcomers arrive with, would make that same habit prohibitively expensive to repeat dozens of times a week. Recognizing that the entire feature-branch and pull-request workflow so much of modern software development is built around depends specifically on branches being this cheap is what connects the low-level pointer mechanism discussed throughout this pair of articles to the everyday, high-level workflow habits built directly on top of it.
Long-running branches versus short-lived ones: a real workflow trade-off
A branch kept alive and actively developed on for weeks while the target branch continues moving forward accumulates real divergence, which eventually makes merging it back a genuinely harder, more conflict-prone operation than merging a branch that only lived for a day or two — this is the practical argument behind trunk-based development and small, frequent feature branches: it is not a stylistic preference so much as a direct consequence of how merge conflict likelihood scales with how long two lines of a shared history are allowed to drift apart from each other before being reconciled.
Branch protection rules: policy enforced at the pointer level
Hosting platforms like GitHub let a team configure branch protection rules — requiring review before merging, requiring status checks to pass, disallowing force-pushes — and understanding branches as pointers clarifies exactly what these rules are actually restricting: not the commits themselves, which anyone with read access can still see and reference freely, but specifically which operations are allowed to move a protected branch's own pointer, and under what conditions. A force-push disallowed on a protected branch, for instance, is disallowed specifically because it would move that branch's pointer to a commit that is not a descendant of its current one, discarding history from anyone else's point of view rather than adding to it — precisely the operation branch protection exists to prevent on branches other people depend on.
Stashing versus branching: two different tools for the same instinct
The instinct to quickly set aside in-progress, uncommitted work to deal with something else — an urgent bugfix, a colleague's question — is often served just as well by `git stash` as by creating a new branch, and knowing when each fits: a stash is the right tool for work that is not yet coherent enough to be a real commit at all, while creating a new branch and committing, even with a rough message meant to be cleaned up later, is usually the better choice once the in-progress work is substantial enough that losing track of it, or forgetting a stash existed at all, would be a genuine loss rather than a minor inconvenience.
Cherry-picking: moving a single commit's changes without moving the pointer
`git cherry-pick` takes one specific commit from anywhere in the repository's history and reapplies just that commit's changes onto the current branch as a new commit, which is a useful, narrower alternative to a full merge or rebase when only a single fix — a hotfix that needs to land on both a release branch and the main development branch, for instance — actually needs to travel between branches, rather than an entire branch's worth of history; understanding it as 'take this one commit's diff and replay it here' rather than as a form of merging clarifies why the newly created commit gets an entirely new hash of its own, just as a rebase's replayed commits do.
Why comparing two branches is comparing two pointers, not two histories from scratch
`git diff branchA..branchB` does not need to reconstruct or compare either branch's entire history to produce a result; it only needs to find the two branches' respective tip commits and compute the difference between the file trees those two specific commits point to, which is exactly why diffing two branches stays fast and cheap regardless of how long each branch's own history happens to be — the pointers name exactly which two snapshots to compare, and everything in each branch's history before that specific tip commit is simply irrelevant to the comparison being asked for.
Why 'git branch' without arguments is nearly free to run at any point
Listing all local branches costs Git almost nothing computationally, regardless of how large the repository's overall history is, because it is only reading a small, fixed set of pointer references rather than traversing any commit history at all — which is worth noting specifically because it reinforces, one more time, the core idea threading through this entire pair of articles: a branch is cheap to create, cheap to list, and cheap to delete precisely because it is nothing more than a name attached to a single commit hash, never a copy of anything the size of the actual project.
Worktrees: having two branches checked out physically at once
`git worktree` lets a second working directory be attached to the same repository with a different branch checked out in it simultaneously, which is a direct, practical consequence of branches being cheap pointers rather than heavy copies — the underlying object database, containing every commit, blob and tree, is shared between both working directories, and only the relatively small overhead of a second checked-out set of files is duplicated, letting two branches be worked on side by side without the cost of a second full clone of the entire repository.
Why CI systems can build every branch in parallel without extra storage cost
A CI system triggering a separate pipeline run for every open branch is not, contrary to how it might sound, duplicating the underlying repository once per branch — every runner typically clones or fetches the same shared object database and simply checks out a different branch pointer, which is exactly why running CI across dozens of simultaneously open branches costs additional compute time but not additional storage proportional to the number of branches, another direct, practical consequence of branches being cheap pointers rather than full copies.
Why renaming a branch is just as cheap as creating one
`git branch -m old-name new-name` does not rebuild or copy anything about the branch's history, it simply changes the label on the same existing pointer, which is why renaming a branch, even one with thousands of commits behind it, completes instantly regardless of how long that history is — one more small, concrete confirmation that everything about a branch's cost lives in the pointer itself, never in the commit history it happens to reference.