Skip to content
Codeloom
Git

Git Submodules vs Subtrees: Choosing the Right Approach

Compare git submodules and subtrees for managing shared code and nested repositories. Learn workflows, trade-offs, and when to use each approach in real projects.

·8 min read · By Codeloom
Intermediate 14 min read

What you'll learn

  • How git submodules work and their core workflow
  • How git subtrees work and when they shine
  • Key differences in cloning, updating, and contributing back
  • Decision framework for choosing between them
  • Advanced patterns for managing shared libraries

Prerequisites

  • Comfortable with Git branching and remotes
  • Understanding of repository structure

The Shared Code Problem

Most projects eventually need to share code across repositories. A design system used by three frontend apps. A protobuf schema shared between services. A utility library maintained by a platform team. Git gives you two built-in mechanisms for this: submodules and subtrees. Both solve the problem, but they make fundamentally different trade-offs.

Git Submodules: Linked Repositories

A submodule is a pointer to a specific commit in another repository. The parent repo stores the URL and the pinned commit hash. The actual submodule code lives in its own directory but is managed as a separate repository.

parent-repo/
.gitmodules          (URL + path mapping)
.git/                (parent repo data)
src/                 (parent code)
libs/shared-ui/      (submodule: separate .git)
  -> pinned to commit abc1234 of github.com/org/shared-ui
Submodule structure

Adding a submodule

git submodule add https://github.com/org/shared-ui.git libs/shared-ui
git commit -m "feat: add shared-ui submodule"

This creates two things:

  • A .gitmodules file mapping the path to the URL.
  • A special entry in the Git tree pointing to the exact commit of the submodule.

Cloning a repo with submodules

When someone clones your repo, submodule directories are empty by default:

# Clone without submodule content
git clone https://github.com/org/parent-repo.git
cd parent-repo
ls libs/shared-ui/    # Empty!

# Initialize and fetch submodules
git submodule update --init --recursive

Or clone everything at once:

git clone --recurse-submodules https://github.com/org/parent-repo.git

Updating a submodule

Submodules are pinned to a specific commit. To update to the latest version:

cd libs/shared-ui
git fetch origin
git checkout main
git pull

cd ..
git add libs/shared-ui
git commit -m "chore: update shared-ui to latest"

Or update all submodules at once:

git submodule update --remote --merge
git add .
git commit -m "chore: update all submodules"

Contributing back to a submodule

Since submodules are full repositories, you can make changes and push them upstream:

cd libs/shared-ui
git checkout -b feature/new-button
# Make changes
git add . && git commit -m "feat: add primary button variant"
git push origin feature/new-button
# Create PR in the shared-ui repo

cd ..
git add libs/shared-ui
git commit -m "chore: point shared-ui to new-button branch"

Git Subtrees: Merged History

A subtree merges another repository’s code directly into your repository. There is no separate .git directory, no pointer — the code is part of your repo’s history. Contributors do not need to know a subtree exists.

parent-repo/
.git/                (single repo, contains all history)
src/                 (parent code)
libs/shared-ui/      (subtree: regular directory, full code)
  -> code merged from github.com/org/shared-ui
Subtree structure

Adding a subtree

git subtree add --prefix=libs/shared-ui https://github.com/org/shared-ui.git main --squash

The --squash flag collapses the subtree’s history into a single merge commit, keeping your log clean.

Without --squash, the full commit history of the subtree is merged into your repo.

Cloning a repo with subtrees

There is nothing special to do. Subtree code is part of the repository:

git clone https://github.com/org/parent-repo.git
# libs/shared-ui/ is already populated with code

This is the biggest advantage of subtrees: zero setup for contributors.

Updating a subtree

Pull the latest changes from the upstream repository:

git subtree pull --prefix=libs/shared-ui https://github.com/org/shared-ui.git main --squash

To avoid typing the URL every time, add it as a remote:

git remote add shared-ui https://github.com/org/shared-ui.git
git subtree pull --prefix=libs/shared-ui shared-ui main --squash

Contributing back from a subtree

Push changes made in the subtree directory back to the upstream repository:

git subtree push --prefix=libs/shared-ui shared-ui feature/new-button

This extracts commits that touched libs/shared-ui/ and pushes them to the upstream repo. It works, but it can be slow on repos with long histories because Git must filter every commit.

Head-to-Head Comparison

Cloning experience

Submodules: Requires --recurse-submodules or a separate git submodule update --init step. New developers frequently forget this and end up with empty directories.

Subtrees: Just git clone. Code is already there. No extra steps.

Repository size

Submodules: The parent repo stays small. Submodule code is fetched separately.

Subtrees: The parent repo contains all the subtree code and potentially its full history. Repository size grows.

Updating shared code

Submodules: Update the submodule commit pointer. Explicit and versioned — you always know which version you are using.

Subtrees: git subtree pull merges upstream changes. Less explicit about versioning.

CI/CD complexity

Submodules: CI pipelines need --recurse-submodules in clone steps. Most CI platforms support this, but it is an extra configuration step that gets forgotten.

# GitHub Actions
- uses: actions/checkout@v4
  with:
    submodules: recursive

Subtrees: No special CI configuration needed.

Contributing changes back

Submodules: Natural. cd into the submodule, work like any repo, push, create PR.

Subtrees: git subtree push works but is slower and less intuitive. Extracting the right commits from mixed history is tricky.

Mental model

Submodules: Two separate repositories linked together. Developers must understand the two-repo model.

Subtrees: One repository with code imported from elsewhere. Developers can ignore the subtree origin entirely.

Decision Framework

Use submodules when:

  • The shared code is actively developed as a standalone project. If shared-ui has its own release cycle, CI, and team, submodules respect that independence.
  • You need strict version pinning. Submodules always point to an exact commit. You update intentionally.
  • Repository size matters. Submodules keep the parent repo lean.
  • Contributors frequently work on the shared code. The submodule workflow (cd, commit, push, update pointer) is natural for two-way development.

Use subtrees when:

  • Contributors should not need to know about the dependency. Subtrees are invisible to developers who do not manage them.
  • The shared code changes infrequently. Occasional subtree pull is simpler than ongoing submodule management.
  • You want simple cloning and CI. No extra flags, no initialization steps.
  • Contributing back upstream is rare. If changes mostly flow from the upstream into your repo, subtrees work well.
  • You want offline access to everything. All code is in one repo.

Avoid both when:

  • A package manager can handle it. If the shared code can be published as an npm package, a PyPI package, or a Go module, use the language’s dependency management. It is purpose-built for this.
  • You need independent versioning and semver. Package managers handle version constraints better than either submodules or subtrees.

Advanced Patterns

Submodule with branch tracking

Instead of pinning to a commit, track a branch:

# Set the submodule to track the 'main' branch
git config -f .gitmodules submodule.libs/shared-ui.branch main

# Update to the latest on the tracked branch
git submodule update --remote libs/shared-ui

Subtree with a split workflow

For large repos where subtree push is slow, use subtree split to extract the subtree history into a separate branch, then push that:

# Split subtree history into a branch
git subtree split --prefix=libs/shared-ui -b shared-ui-split

# Push the split branch to the upstream remote
git push shared-ui shared-ui-split:main

Multiple submodules with foreach

Run commands across all submodules:

# Update all submodules to latest
git submodule foreach 'git pull origin main'

# Check status of all submodules
git submodule foreach 'git status'

# Run tests in each submodule
git submodule foreach 'npm test || true'

Sparse submodule checkout

If you only need part of a submodule, combine with sparse checkout:

cd libs/shared-ui
git sparse-checkout init
git sparse-checkout set src/components

Common Pitfalls

Submodule pitfalls:

  • Forgetting to push submodule changes before pushing the parent. The parent now points to a commit that does not exist on the remote.
  • Not running git submodule update after switching branches, leaving submodules out of sync.
  • Detached HEAD in submodules confuses developers who are not familiar with the model.

Subtree pitfalls:

  • Forgetting the --prefix flag leads to merging code into the wrong directory.
  • Using --squash inconsistently causes merge conflicts on subsequent pulls.
  • History bloat when importing large repositories without --squash.

Wrap-Up

Submodules and subtrees solve the same problem with opposite philosophies. Submodules keep repositories separate and linked — explicit, versioned, but requiring more setup and awareness. Subtrees merge everything into one repository — simple to clone and work with, but harder to contribute back upstream. Choose submodules when the shared code is a first-class project with its own lifecycle. Choose subtrees when the shared code is a dependency that most developers never need to think about. And always consider whether a proper package manager might be the better answer.