Git submodules without surprise detached HEADs
Submodules embed one repo inside another by pinning a commit, not a branch. Knowing that single fact prevents most of the pain — detached HEADs, missed pushes, and silent divergence after a pull.
On this page
A submodule is a reference from a parent repository to a specific commit in another repository. Not a branch. Not a tag. A commit. Once that fact is in your head, every quirk of submodule behaviour — the detached HEAD on update, the “submodule changed” entries in diff, the way git pull doesn’t fetch them by default — stops being surprising.
Reach for submodules when you genuinely need to embed a separately-versioned repository (a vendored library you also maintain, a shared component used by multiple parents). For dependency management, prefer a real package manager — Maven, npm, Go modules, Cargo — every time.
The mental model
The parent repository stores, for each submodule:
- A path (where it lives in the working tree).
- A URL (where to clone it from — recorded in
.gitmodules, which is tracked). - A commit SHA (which commit of that other repo this parent version points at — stored as a tree entry with mode
160000).
When you check out a parent commit, Git restores the recorded SHA in each submodule by checking out that exact commit. Since commits aren’t branches, the submodule lands in detached HEAD state. That’s the source of most submodule pain.
The .gitmodules file
Tracked, version-controlled, and shared with every clone.
[submodule "DbConnector"]
path = DbConnector
url = https://github.com/example/DbConnector
branch = stable
The branch key is advisory — it’s what git submodule update --remote uses when fetching the latest tip. The parent still pins a specific commit; the branch hint just says “when I ask for an update, follow this branch.”
If your team has a private fork or a credentials-sensitive URL, override locally without committing:
git config submodule.DbConnector.url <private-url>
Cloning a repo that has submodules
# Preferred: clone the parent and all submodules in one shot
git clone --recurse-submodules <url>
# Already cloned without --recurse?
git submodule update --init --recursive
The --init initialises .git/config entries from .gitmodules. The --recursive flag handles nested submodules. Both are safe to re-run.
Adding a submodule
git submodule add https://github.com/example/DbConnector
git commit -m "Add DbConnector submodule"
This creates the .gitmodules entry, clones the submodule into place, and stages two changes: the new .gitmodules and the submodule path itself (which Git records as a 160000 commit pointer).
Updating a submodule
There are two distinct operations here, and they’re routinely confused.
“Restore submodules to the parent’s pinned commits”
git submodule update --init --recursive
This reads the SHAs recorded in the parent’s current commit and checks each submodule out to that SHA. It does not pull new commits from the submodule’s remote.
“Pull new commits from the submodule’s remote and update the parent’s pin”
# Fetch each submodule's tracking branch and check out its tip
git submodule update --remote
# Or for a specific submodule
git submodule update --remote DbConnector
# Then commit the new SHA into the parent
git add DbConnector
git commit -m "Bump DbConnector to <new-sha>"
The first form is the one most people want when they pull a parent. The second is the explicit “bring in upstream changes” workflow.
The detached HEAD trap
After any submodule update, the submodule is in detached HEAD. Make changes there without thinking, and the next update silently moves HEAD away — your work is reachable only via the reflog.
cd DbConnector
git checkout stable # check out a real branch
# edit, commit, push from inside the submodule
cd ..
git submodule update --remote --merge # fold upstream into your local branch
--merge (or --rebase) makes update --remote integrate upstream changes into your current branch rather than blowing away your work.
The push-order trap
cd DbConnector
git commit -m "Fix the bug"
git push # submodule pushed: OK
cd ..
git add DbConnector
git commit -m "Bump DbConnector"
git push # parent pushed: OK from your side, broken for everyone else
If you pushed the parent without first pushing the submodule, every collaborator who clones the parent will try to fetch a SHA that doesn’t exist on the submodule’s remote. They get a checkout error. You get a Slack message.
Two configs make this unforgettable:
git config push.recurseSubmodules check
# or, more aggressive:
git config push.recurseSubmodules on-demand
check refuses the parent push if any submodule has unpushed commits. on-demand automatically pushes the submodules first. Pick one; either is far better than the default of “trust the human.”
Switching branches in the parent
If two branches of the parent point at different submodule SHAs, switching between them needs to also re-check-out the submodule:
git checkout --recurse-submodules main
# or set it once:
git config submodule.recurse true
Without this, Git switches the parent but leaves the submodules at their previous SHAs — silently mixing two versions. The flag (and the config) are available from Git 2.13/2.14 onwards.
Removing a submodule
git submodule deinit -f DbConnector
git rm -f DbConnector
git commit -m "Remove DbConnector submodule"
# Optional: also delete .git/modules/DbConnector if you want to fully wipe local cache.
Useful defaults to set once
git config submodule.recurse true # branch switches recurse
git config status.submodulesummary 1 # status shows submodule changes
git config diff.submodule log # diff shows submodule commit ranges
git config push.recurseSubmodules check # block parent push if submodule unpushed
Together these turn most of the “wait, how did this happen?” moments into compile-time errors instead of three-Slack-thread incidents.
The “should we use submodules at all?” question
A lib/ directory full of submodules — each a popular OSS library — wired up because “we want exact version pins.”
The author wanted reproducible builds and reached for the Git tool. Submodules feel like “pinning” in a way that package.json doesn’t.
package.json, pom.xml, go.mod, Cargo.toml and friends all support exact-version pinning, and they don’t subject your team to detached HEADs and forgotten pushes. Use submodules when the embedded repo is yours (or a fork you maintain), not for general dependencies.
Legitimate uses:
- A vendored fork you actively maintain — a forked library with internal patches.
- A repository you author and ship to multiple consumers — a shared component used by 3 parent products, where each parent pins a known-good commit.
- A separately-secured subtree — a submodule with different access controls than the parent.
If none of those apply, you almost certainly want a package manager instead.
Quick reference
git submodule add <url> [path] # add
git clone --recurse-submodules <url> # clone with submodules
git submodule update --init --recursive # restore to parent's pinned SHAs
git submodule update --remote [--merge] # pull upstream, update parent's pin
git submodule foreach '<cmd>' # run command in each submodule
git submodule status # show SHAs and branches
git submodule deinit -f <path> # uninitialise
git rm -f <path> # remove from parent
Related: 0024 Git worktrees for parallel branch work · Git if you’re juggling submodules across multiple worktrees — there are extra sharp edges there worth knowing.
Related
- Git· mutualThree ways to squash, and when each is rightInteractive rebase, soft reset, and merge --squash all collapse commits into fewer commits. They differ in granularity, on which branch they leave the result, and how much history they preserve.
- Git· mutualBranching strategies: Git Flow, GitHub Flow, trunk-basedHow the three dominant branching models differ in branch count, integration cadence, and release rhythm — and how to pick one based on your release model, not the model's brand.
- Git· relatedGit rebase without losing workWhat rebase actually does to your commits, how to drive the interactive form, and the recovery moves that keep a bad rebase from becoming a lost-work incident.