Git Submodules: The Complete Guide for 2026

Published February 12, 2026 · 25 min read

Git submodules let you embed one Git repository inside another as a dependency. They are the built-in way to share libraries, configuration, or any code between projects while keeping each repository independent. Despite their reputation for being confusing, submodules are straightforward once you understand the mental model: the parent repo stores a pointer (a specific commit SHA) to a child repo at a specific path.

This guide covers every aspect of working with submodules: adding and cloning them, updating to new versions, working with branches, committing changes, removing them cleanly, and integrating them into CI/CD pipelines. We also compare submodules against alternatives like subtrees, monorepos, and language-specific package managers so you can pick the right tool for your project.

Table of Contents

What Are Git Submodules?

A Git submodule is a reference to another Git repository at a specific commit, embedded inside a parent repository at a given path. The parent does not store the submodule's files in its own history. Instead, it records two things:

When you look at a submodule entry in the parent repo, Git shows it as a single "commit" object rather than a tree of files:

# In the parent repository
$ git ls-tree HEAD
100644 blob a1b2c3d  .gitmodules
160000 commit e4f5g6h  libs/shared-utils

That 160000 mode is the special "gitlink" type, telling Git this path points to a commit in another repository. The submodule directory contains a complete Git repository with its own .git, its own branches, and its own history.

When to Use Submodules

Submodules work best when you need to:

Submodules are a poor fit when contributors rarely touch the submodule code, when you need tight coupling between repos, or when the team is not comfortable with the extra Git commands. In those cases, consider the alternatives section below.

Adding Submodules

The git submodule add command registers an external repository as a submodule:

# Basic: add a submodule at the default path (repo name)
git submodule add https://github.com/your-org/shared-utils.git

# Specify a custom path
git submodule add https://github.com/your-org/shared-utils.git libs/shared-utils

# Add from a specific branch
git submodule add -b main https://github.com/your-org/shared-utils.git libs/shared-utils

This does three things:

  1. Clones the external repository into libs/shared-utils
  2. Creates (or updates) the .gitmodules file with the URL and path
  3. Stages both the .gitmodules file and the submodule path for commit

Now commit to record the submodule in the parent:

git commit -m "Add shared-utils as submodule at libs/shared-utils"

After this commit, anyone who clones your repository will see the libs/shared-utils directory, but it will be empty until they initialize the submodule.

Cloning Repos with Submodules

The most common source of confusion with submodules is cloning a project and finding empty directories. By default, git clone does not initialize submodules.

Option 1: Clone with --recurse-submodules (Recommended)

# Clone and initialize all submodules in one step
git clone --recurse-submodules https://github.com/your-org/main-project.git

# This handles nested submodules too (submodules within submodules)

Option 2: Initialize After Cloning

# If you already cloned without submodules
git submodule init
git submodule update

# Or combine both steps
git submodule update --init

# For nested submodules, add --recursive
git submodule update --init --recursive

Option 3: Shallow Submodule Clone

For large submodules where you only need the current commit, use a shallow clone:

# Clone submodules with depth 1 (only the pinned commit)
git clone --recurse-submodules --shallow-submodules https://github.com/your-org/main-project.git

# Or after cloning
git submodule update --init --depth 1

This is particularly useful in CI/CD pipelines where build speed matters and you never browse submodule history.

Updating Submodules

There are two kinds of submodule updates, and confusing them is one of the most common pitfalls.

Update to the Commit Recorded by the Parent

This is what you do after pulling changes in the parent repo that updated the submodule pointer:

# Someone else updated the submodule reference; pull their changes
git pull origin main

# Now update submodules to match the new pointers
git submodule update

# With --recursive for nested submodules
git submodule update --recursive

This checks out the exact commit the parent repo specifies. It does not fetch anything new from the submodule's remote.

Update to the Latest Remote Commit

This fetches new commits from the submodule's remote and updates the checkout:

# Update a specific submodule to the latest remote commit
git submodule update --remote libs/shared-utils

# Update all submodules to their latest remote commits
git submodule update --remote

# Update and merge (instead of checkout)
git submodule update --remote --merge

# Update and rebase local changes on top
git submodule update --remote --rebase

After running --remote, the submodule is now at a newer commit than what the parent records. You need to commit this change in the parent:

# Stage the updated submodule reference
git add libs/shared-utils
git commit -m "Update shared-utils to latest version"

For more on managing branches when updating, see the Git Branching Strategies Guide.

.gitmodules File Explained

The .gitmodules file lives at the root of your repository and maps submodule paths to their remote URLs and configuration. It is a plain INI-style file tracked by Git:

[submodule "libs/shared-utils"]
    path = libs/shared-utils
    url = https://github.com/your-org/shared-utils.git
    branch = main

[submodule "vendor/payment-sdk"]
    path = vendor/payment-sdk
    url = git@github.com:your-org/payment-sdk.git
    branch = stable
    shallow = true

[submodule "tools/linter-config"]
    path = tools/linter-config
    url = https://github.com/your-org/linter-config.git

Key Fields

The .gitmodules file is the public configuration that all collaborators share. There is also a per-user configuration in .git/config that gets populated when you run git submodule init. You can override URLs locally without changing the shared file:

# Override a submodule URL in your local config only
git config submodule.libs/shared-utils.url git@github.com:my-fork/shared-utils.git

Working with Submodule Branches

By default, submodules are checked out in "detached HEAD" state at the exact commit the parent records. This is intentional — it guarantees reproducible builds. But it means you cannot commit directly without first creating or checking out a branch.

Configuring a Tracking Branch

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

# Or edit .gitmodules directly to add: branch = main

Now git submodule update --remote will fetch the latest commit from main instead of whatever the remote HEAD is.

Checking Out a Branch Inside a Submodule

# Enter the submodule
cd libs/shared-utils

# You are in detached HEAD state. Check out a branch:
git checkout main

# Or create a feature branch
git checkout -b feature/add-validation

# Make changes, commit, push as normal
git add .
git commit -m "Add input validation helpers"
git push origin feature/add-validation

# Return to the parent repo
cd ../..

# The parent now sees the submodule at your new commit
git add libs/shared-utils
git commit -m "Update shared-utils: add input validation"

Keeping All Submodules on Branches

# Run a command inside every submodule
git submodule foreach 'git checkout main || true'

# Fetch latest in all submodules
git submodule foreach 'git pull origin main'

Committing Changes in Submodules

The workflow for making changes inside a submodule has two parts: committing in the submodule itself, then updating the parent's reference.

Step-by-Step Workflow

# 1. Enter the submodule
cd libs/shared-utils

# 2. Make sure you are on a branch (not detached HEAD)
git checkout main

# 3. Make your changes
vim src/helpers.ts

# 4. Commit inside the submodule
git add src/helpers.ts
git commit -m "Add string sanitization helper"

# 5. Push the submodule changes to its remote
git push origin main

# 6. Go back to the parent repository
cd ../..

# 7. The parent sees the submodule has moved to a new commit
git status
# modified:   libs/shared-utils (new commits)

# 8. Stage and commit the new submodule reference
git add libs/shared-utils
git commit -m "Update shared-utils: add string sanitization"

# 9. Push the parent
git push origin main

Critical rule: Always push the submodule before pushing the parent. If you push the parent first, other developers will pull a submodule reference that points to a commit they cannot fetch.

Preventing Broken References

# Push the parent only if all submodules have been pushed
git push --recurse-submodules=check origin main

# Or automatically push submodules first, then the parent
git push --recurse-submodules=on-demand origin main

Set this as the default behavior:

git config --global push.recurseSubmodules on-demand

Removing Submodules Properly

Removing a submodule is the most notoriously fiddly operation. Here is the complete, reliable process:

# 1. Deinit the submodule (removes it from .git/config)
git submodule deinit -f libs/shared-utils

# 2. Remove from the working tree and .gitmodules
git rm -f libs/shared-utils

# 3. Clean up the .git/modules directory
rm -rf .git/modules/libs/shared-utils

# 4. Commit the removal
git commit -m "Remove shared-utils submodule"

All four steps are necessary for a clean removal. If you skip step 3, the cached module data remains and can cause issues if you later add a different submodule at the same path.

Quick Reference

Step Command What It Does
1git submodule deinit -f <path>Unregisters the submodule, clears its working tree
2git rm -f <path>Removes from index and updates .gitmodules
3rm -rf .git/modules/<path>Deletes the cached submodule Git data
4git commitRecords the removal in history

Submodules vs Subtrees vs Monorepos

Submodules are not the only way to include external code. Here is how the three main approaches compare:

Aspect Submodules Subtrees Monorepo
Repo structureSeparate repos linked by referenceExternal code merged into your treeAll code in one repo
Clone complexityRequires --recurse-submodulesStandard cloneStandard clone (may be large)
Version pinningExact commit SHAManual (squash commit)Always latest (HEAD)
Upstream contributionsEasy (push from submodule)Possible but awkwardN/A (same repo)
Contributor frictionHigh (extra commands)Low (files are just there)Low
Access controlPer-repo permissionsSame as parentSame repo (use CODEOWNERS)
CI/CD setupNeeds submodule init stepNo extra stepsNo extra steps

Choose Submodules When:

Choose Subtrees When:

Choose a Monorepo When:

CI/CD with Submodules

CI/CD pipelines are the most common place submodules break. By default, most CI systems only clone the parent repo without initializing submodules. Here is how to fix it for the major platforms.

GitHub Actions

# .github/workflows/build.yml
name: Build
on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive  # Initialize all submodules
          # For private submodules, use a PAT or SSH key:
          # token: ${{ secrets.SUBMODULE_PAT }}

      - run: npm install
      - run: npm test

If your submodules are in private repositories, you need a Personal Access Token (PAT) with repo scope or configure SSH keys. See our GitHub Actions CI/CD Guide for detailed setup.

GitLab CI

# .gitlab-ci.yml
variables:
  GIT_SUBMODULE_STRATEGY: recursive
  # For private submodules, use:
  # GIT_SUBMODULE_FORCE_HTTPS: "true"

stages:
  - build
  - test

build:
  stage: build
  script:
    - npm install
    - npm run build

test:
  stage: test
  script:
    - npm test

Generic CI (Jenkins, CircleCI, etc.)

# Add this to your build steps after checkout
git submodule update --init --recursive

# For faster builds, use shallow submodule clones
git submodule update --init --recursive --depth 1

SSH Key Configuration for Private Submodules

# In CI, set up SSH before cloning submodules
mkdir -p ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan github.com >> ~/.ssh/known_hosts

# Alternative: rewrite HTTPS URLs to use a token
git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "git@github.com:"

Common Pitfalls and Solutions

Pitfall 1: Empty Submodule Directories After Clone

Problem: You cloned a repo and the submodule directories exist but are empty.

Solution: Run git submodule update --init --recursive. Next time, clone with --recurse-submodules.

Pitfall 2: Detached HEAD When Entering a Submodule

Problem: You cd into a submodule and find yourself on "(no branch)".

Solution: This is normal. Submodules default to detached HEAD at the pinned commit. If you need to make changes, check out a branch first: git checkout main.

Pitfall 3: Pushing the Parent Before the Submodule

Problem: You commit a new submodule reference in the parent and push it. Colleagues pull and get an error because the submodule commit does not exist on the remote.

Solution: Always push the submodule first. Or use git push --recurse-submodules=on-demand which pushes submodules automatically.

Pitfall 4: Forgetting to Commit the Submodule Update in the Parent

Problem: You run git submodule update --remote, the submodule advances, but you forget to git add and commit the new reference in the parent. Other developers never see the update.

Solution: After any --remote update, always stage and commit: git add <submodule-path> && git commit.

Pitfall 5: Merge Conflicts on Submodule Pointers

Problem: Two developers updated the same submodule to different commits. Git reports a conflict on the submodule path.

Solution: Decide which commit to use, then:

# Check out the version you want
cd libs/shared-utils
git checkout <desired-commit>
cd ../..

# Stage the resolution
git add libs/shared-utils
git rebase --continue  # or git merge --continue

For more on conflict resolution, see our Git Rebase Complete Guide.

Pitfall 6: Submodule URL Changes

Problem: The submodule repo moved to a new URL. Everyone's clones point to the old one.

Solution: Update .gitmodules, sync, and commit:

# Update the URL in .gitmodules
git config -f .gitmodules submodule.libs/shared-utils.url https://github.com/new-org/shared-utils.git

# Sync the URL to .git/config
git submodule sync

# Commit the change
git add .gitmodules
git commit -m "Update shared-utils submodule URL"

Best Practices for Managing Dependencies

1. Pin to Tagged Releases

Instead of tracking a branch tip, pin submodules to version tags for stability:

cd libs/shared-utils
git fetch --tags
git checkout v2.4.1
cd ../..
git add libs/shared-utils
git commit -m "Pin shared-utils to v2.4.1"

2. Use --recurse-submodules Everywhere

Configure Git to handle submodules automatically:

# Auto-update submodules on pull, checkout, and switch
git config --global submodule.recurse true

# This is equivalent to passing --recurse-submodules to
# git pull, git checkout, git switch, and git read-tree

3. Document Submodule Setup in Your README

New contributors will not know about submodules unless you tell them. Add clone instructions to your README:

# Clone with submodules
git clone --recurse-submodules https://github.com/your-org/project.git

# Or if already cloned
git submodule update --init --recursive

4. Set Up Push Safety

# Prevent pushing parent if submodule commits are not pushed
git config --global push.recurseSubmodules on-demand

5. Use a Wrapper Script for Complex Setups

For projects with many submodules, create a setup script:

#!/bin/bash
# setup.sh - Initialize the project
set -e

echo "Initializing submodules..."
git submodule update --init --recursive

echo "Checking out tracked branches..."
git submodule foreach 'git checkout $(git config -f $toplevel/.gitmodules submodule.$sm_path.branch || echo main)'

echo "Project ready."

6. Review Submodule Diffs Before Committing

# See which submodules changed and to which commits
git diff --submodule

# More detailed: show the log of new commits in submodules
git diff --submodule=log

# Show submodule changes in status
git status --short

Alternatives to Submodules

Git Subtree

Git subtree merges an external repo's files directly into your project. No .gitmodules, no detached HEAD, no init step:

# Add a subtree
git subtree add --prefix=libs/shared-utils https://github.com/your-org/shared-utils.git main --squash

# Pull updates from the remote
git subtree pull --prefix=libs/shared-utils https://github.com/your-org/shared-utils.git main --squash

# Push local changes back upstream
git subtree push --prefix=libs/shared-utils https://github.com/your-org/shared-utils.git main

The trade-off: your repo's history grows with every subtree pull, and contributing back upstream is more complex than with submodules.

Language-Specific Package Managers

For published libraries, package managers are usually the better choice:

Use submodules when the dependency is unpublished, requires local modifications, or spans multiple languages.

Git Sparse Checkout + Partial Clone

For monorepo-style setups where you want to avoid cloning everything:

# Clone without downloading all blobs
git clone --filter=blob:none --sparse https://github.com/your-org/monorepo.git

# Check out only the directories you need
cd monorepo
git sparse-checkout set services/api libs/shared-utils

This combines the benefits of a monorepo (atomic commits, single clone) with the performance of smaller checkouts. For projects using worktrees alongside this, see our Git Worktrees Complete Guide.

Frequently Asked Questions

What is the difference between git submodules and git subtrees?

Git submodules store a reference (URL and commit SHA) to an external repository, keeping it as a separate Git repo inside your project. Git subtrees merge the external repository's files directly into your project tree, so there is no separate .git directory or .gitmodules file. Submodules require contributors to run extra commands (git submodule init/update) but keep a clean separation. Subtrees are simpler for contributors since the files are just there, but they bloat your repository history and make upstream contributions harder. Use submodules when you need clear boundaries and version pinning; use subtrees when you want zero friction for contributors.

How do I clone a repository that contains submodules?

Use git clone --recurse-submodules <url> to clone the repository and all its submodules in one step. If you already cloned without that flag, run git submodule init followed by git submodule update, or combine them with git submodule update --init --recursive. The --recursive flag ensures nested submodules (submodules inside submodules) are also initialized and checked out.

How do I properly remove a git submodule?

Removing a submodule requires several steps: first run git submodule deinit -f <path> to unregister it, then run git rm -f <path> to remove it from the working tree and .gitmodules, and finally delete the leftover directory in .git/modules/<path> with rm -rf .git/modules/<path>. Commit the changes after these steps. Since Git 2.12, git rm <path> handles most of the cleanup, but the .git/modules directory must still be removed manually to fully clean up.

How do I update a submodule to the latest commit on its remote branch?

Run git submodule update --remote <submodule-path> to fetch the latest commit from the submodule's tracked branch (defaults to the remote HEAD, usually main). This updates the submodule checkout to the latest remote commit. You then need to commit the updated submodule reference in the parent repository with git add <submodule-path> and git commit. To update all submodules at once, omit the path: git submodule update --remote.

Why does my CI/CD pipeline fail with empty submodule directories?

Most CI/CD systems clone repositories without initializing submodules by default. In GitHub Actions, set the submodules option to recursive in the actions/checkout step. In GitLab CI, set GIT_SUBMODULE_STRATEGY: recursive in your variables. For other CI systems, add git submodule update --init --recursive as a build step after checkout. If submodules are in private repos, ensure your CI has SSH keys or tokens with access to those repositories.

Continue Learning

This guide is part of our Git deep-dive series. Explore the related guides to build a complete understanding of Git workflows:

Related Tools

Git Diff Viewer
Visualize and compare diffs in the browser
JSON Formatter
Format and validate JSON data instantly
Regex Tester
Test regular expressions with live matching
Git Cheat Sheet
One-page quick reference for all Git commands