Skip to content

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.

Updated May 26, 2026

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

Anti-pattern
Submodules as a poor man's package manager
Smell

A lib/ directory full of submodules — each a popular OSS library — wired up because “we want exact version pins.”

Why it happens

The author wanted reproducible builds and reached for the Git tool. Submodules feel like “pinning” in a way that package.json doesn’t.

Fix instead

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