Git Submodules: The Complete Guide for 2026

Published February 12, 2026 · 25 min read

Git submodules let you embed one Git repository inside another. They are the standard mechanism for including shared libraries, vendored dependencies, or any project that maintains its own history and release cycle. Submodules are powerful but notoriously confusing — the commands are verbose, the mental model is different from regular Git, and mistakes can leave your repository in a puzzling state.

This guide covers everything you need to use submodules confidently: adding and removing them, cloning repos that contain them, updating strategies, branch tracking, nested submodules, CI/CD pipelines, and a clear comparison with alternatives like subtree and monorepos.

Table of Contents

What Are Git Submodules?

A Git submodule is a pointer from one repository (the parent) to a specific commit in another repository (the child). The parent does not store the child's files directly. Instead, it records two things: the URL of the child repository and the exact commit hash to check out.

Parent repository
├── .gitmodules          # Declares submodule URLs and paths
├── .git/modules/        # Stores submodule Git databases
├── src/
│   └── app.js
└── libs/
    └── shared-utils/    # Submodule: a full Git repo at a pinned commit
        ├── .git         # File pointing to ../../.git/modules/libs/shared-utils
        ├── index.js
        └── package.json

When you look at the parent repo's commit history, the submodule appears as a special entry (a "gitlink") that records the commit hash. Git does not track the submodule's individual files — it only tracks which commit the submodule should be at.

When to Use Submodules

When NOT to Use Submodules

Adding Submodules

The git submodule add command registers a new submodule in your repository:

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

# Add a submodule at a specific path
git submodule add https://github.com/org/shared-utils.git libs/shared-utils

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

This command does three things:

  1. Clones the external repository into the specified path
  2. Creates or updates the .gitmodules file with the submodule's URL and path
  3. Stages both the .gitmodules file and the new submodule directory (as a gitlink)

You still need to commit:

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

After committing, 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

When you clone a repository that contains submodules, the submodule directories exist but are empty by default. You have two options:

Option 1: Clone with --recurse-submodules

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

# Same thing, older syntax (still works)
git clone --recursive https://github.com/org/my-project.git

This is the simplest approach and should be your default when cloning any project that uses submodules.

Option 2: Initialize After Cloning

If you already cloned without the flag:

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

# Initialize and fetch all submodules, including nested ones
git submodule update --init --recursive

The --init flag registers submodules listed in .gitmodules into your local .git/config. The --recursive flag handles submodules that themselves contain submodules. Always use --recursive unless you know there are no nested submodules.

Selective Initialization

In large projects with many submodules, you might not need all of them:

# Initialize only specific submodules
git submodule init libs/shared-utils libs/auth-module
git submodule update

# Or use a single command for specific paths
git submodule update --init libs/shared-utils

Updating Submodules

Updating submodules is the operation that causes the most confusion. There are two distinct scenarios: checking out the commit the parent expects, and pulling new changes from the submodule's remote.

Scenario 1: Sync to the Parent's Recorded Commit

After a git pull on the parent repo, submodules may point to different commits than what you have checked out locally. Run:

# Update all submodules to the commits recorded by the parent
git submodule update --recursive

# If submodules were recently added and not yet initialized
git submodule update --init --recursive

This checks out the exact commit the parent repo says each submodule should be at. It does not fetch new commits from the submodule's remote — it only adjusts the local checkout.

Scenario 2: Pull New Changes from Upstream

To update a submodule to the latest commit on its tracked branch:

# Fetch and merge the latest changes for all submodules
git submodule update --remote

# Fetch and merge for a specific submodule
git submodule update --remote libs/shared-utils

# Fetch and rebase instead of merge
git submodule update --remote --rebase

After updating with --remote, the parent repo will show the submodule as modified (because the recorded commit changed). You need to commit this change:

git add libs/shared-utils
git commit -m "Update shared-utils to latest upstream"

Updating on Every Pull

Configure Git to automatically update submodules when you pull:

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

With this setting, git pull behaves like git pull && git submodule update --init --recursive.

Tracking Branches vs Specific Commits

By default, submodules are pinned to a specific commit hash. This is the safest approach because your build is fully reproducible — no matter when someone clones, they get the exact same code. But sometimes you want a submodule to follow a branch.

Pinned Commits (Default)

# The parent records a specific commit hash
$ git submodule status
 a1b2c3d4e5f6... libs/shared-utils (v2.3.1)

# To update the pin, cd into the submodule and check out a new commit
cd libs/shared-utils
git fetch origin
git checkout v2.4.0
cd ../..

# Then commit the new pin in the parent
git add libs/shared-utils
git commit -m "Bump shared-utils to v2.4.0"

Branch Tracking

To have a submodule follow a branch, set it in .gitmodules:

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

# Now --remote will fetch the latest commit on that branch
git submodule update --remote libs/shared-utils

Even with branch tracking, the parent still records a specific commit hash. The difference is that --remote knows which branch to look at when fetching updates. You still need to commit the updated hash in the parent.

Which Approach to Choose

Aspect Pinned Commit Branch Tracking
ReproducibilityExact — always the same codeDepends on when you last ran --remote
Update effortManual checkout and commitOne command: update --remote
Best forStable dependencies, productionActive development, integration testing
RiskFalling behind upstreamBreaking changes from upstream

Removing Submodules

Removing a submodule is the most frequently searched submodule operation because it requires multiple steps. Simply deleting the directory is not enough — Git stores submodule metadata in three locations.

Complete Removal Process

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

# Step 2: Remove the submodule entry and directory (updates .gitmodules and staging)
git rm -f libs/shared-utils

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

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

What Each Step Does

If you skip the rm -rf .git/modules/ step and later try to re-add the submodule, Git may use the stale cached data and produce confusing errors. Always complete all three steps.

.gitmodules File Explained

The .gitmodules file is a configuration file tracked by Git that declares all submodules in the project. Here is an example:

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

[submodule "libs/auth-module"]
    path = libs/auth-module
    url = https://github.com/org/auth-module.git

[submodule "vendor/legacy-parser"]
    path = vendor/legacy-parser
    url = https://github.com/thirdparty/legacy-parser.git
    shallow = true

Key Fields

Overriding URLs Locally

Each developer can override a submodule's URL without changing the committed .gitmodules:

# Use SSH instead of HTTPS for a submodule
git config submodule.libs/shared-utils.url git@github.com:org/shared-utils.git

# Sync URLs from .gitmodules to .git/config
git submodule sync

The sync command is useful after .gitmodules changes (e.g., a repository was moved to a new URL). It updates your local .git/config to match.

Nested Submodules

Submodules can contain their own submodules. This is common in large ecosystems where a shared library depends on other shared libraries. Git handles this through recursive operations.

parent-project/
├── libs/
│   └── framework/           # Submodule
│       ├── src/
│       └── deps/
│           └── core-lib/    # Nested submodule (submodule within a submodule)
│               └── src/

Working with Nested Submodules

# Initialize and update all levels of submodules
git submodule update --init --recursive

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

# Fetch upstream changes for all nested submodules
git submodule update --remote --recursive

# Run a command in every submodule (including nested ones)
git submodule foreach --recursive 'git fetch origin'

The --recursive flag is the key. Without it, Git only processes the top-level submodules. With it, Git descends into each submodule and processes its submodules too, all the way down.

The foreach Command

The foreach command runs an arbitrary shell command in every submodule:

# Check the status of every submodule
git submodule foreach 'git status'

# Pull latest changes in every submodule
git submodule foreach 'git pull origin main'

# Show which branch each submodule is on
git submodule foreach 'echo "$name: $(git branch --show-current)"'

# Clean all submodules
git submodule foreach --recursive 'git clean -fd'

CI/CD with Submodules

Submodules require extra configuration in CI/CD pipelines because most runners do a shallow clone without initializing submodules. Here is how to handle them in popular CI systems.

GitHub Actions

steps:
  - name: Checkout with submodules
    uses: actions/checkout@v4
    with:
      submodules: recursive   # 'true' for top-level only, 'recursive' for nested
      token: ${{ secrets.PAT_TOKEN }}  # needed if submodules are private repos

If your submodules are in private repositories, the default GITHUB_TOKEN may not have access. Use a Personal Access Token (PAT) with repo scope.

GitLab CI

variables:
  GIT_SUBMODULE_STRATEGY: recursive  # 'normal' for top-level only
  GIT_SUBMODULE_DEPTH: 1             # shallow clone submodules for speed

build:
  script:
    - echo "Submodules are already initialized"

Jenkins

pipeline {
    agent any
    stages {
        stage('Checkout') {
            steps {
                checkout([
                    $class: 'GitSCM',
                    extensions: [[$class: 'SubmoduleOption',
                        recursiveSubmodules: true,
                        trackingSubmodules: false
                    ]],
                    userRemoteConfigs: [[url: 'https://github.com/org/project.git']]
                ])
            }
        }
    }
}

Generic CI (Bash)

For any CI system that gives you a basic checkout, add this step:

# After the initial clone/checkout
git submodule sync --recursive
git submodule update --init --recursive --depth 1

The --depth 1 flag performs a shallow clone of each submodule, significantly speeding up CI pipelines by skipping full history downloads.

For more CI/CD patterns, see our GitHub Actions CI/CD Complete Guide.

Submodules vs Subtree vs Monorepo

Submodules are not the only way to include external code. Here is how they compare with the two main alternatives.

Git Subtree

git subtree merges an external repository's files directly into your project tree. There is no special metadata, no .gitmodules file, and no extra init steps for consumers.

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

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

# Push changes back to the original repo
git subtree push --prefix=libs/shared-utils https://github.com/org/shared-utils.git main

Monorepo

A monorepo puts all projects in a single repository. Tools like Nx, Turborepo, Bazel, and Pants handle builds and dependency management within the monorepo.

Comparison Table

Aspect Submodule Subtree Monorepo
External repo stays separateYesFiles are merged inNo external repo
Consumer complexityMust run init/updateJust clone, files are thereJust clone
Push changes upstreamcd into submodule, commit, pushgit subtree pushN/A (same repo)
HistorySeparate per repoMixed into parentSingle unified history
Version pinningExact commit hashSquash commit in parentEverything at HEAD
CI/CD setupNeeds init/update stepNo extra stepsNeeds build tool (Nx, Bazel)
Best forIndependent projects, stable depsSimple inclusion, rare upstream pushesTightly coupled projects, large orgs

Common Pitfalls and Troubleshooting

Pitfall 1: Detached HEAD in Submodules

Problem: Every submodule is checked out with a detached HEAD, which is confusing if you try to make commits inside it.

Solution: This is by design. Before making changes in a submodule, check out a branch first:

cd libs/shared-utils
git checkout main
# ... make changes, commit, push ...
cd ../..
git add libs/shared-utils
git commit -m "Update shared-utils submodule"

Pitfall 2: Forgetting to Push Submodule Changes

Problem: You commit a submodule update in the parent that points to a commit that only exists on your local machine. When teammates pull, the submodule update fails because the commit does not exist on the remote.

Solution: Always push the submodule first, then push the parent:

# Push the submodule
cd libs/shared-utils
git push origin main
cd ../..

# Then push the parent
git push origin main

# Or use --recurse-submodules to push both
git push --recurse-submodules=on-demand

The --recurse-submodules=on-demand flag automatically pushes any submodules that have local commits before pushing the parent. Make it the default:

git config --global push.recurseSubmodules on-demand

Pitfall 3: Submodule Shows as Modified After Pull

Problem: After git pull, git status shows the submodule as modified even though you did not change anything.

Solution: The parent pulled a new commit hash for the submodule, but your local submodule checkout has not been updated yet. Run:

git submodule update --recursive

Pitfall 4: Permission Errors with Private Submodules

Problem: git submodule update fails with authentication errors because the submodule URL requires credentials you have not configured.

Solution: Use SSH URLs in .gitmodules or configure a credential helper. For CI, use deploy keys or access tokens:

# Convert HTTPS URLs to SSH for all submodules
git config --global url."git@github.com:".insteadOf "https://github.com/"

Pitfall 5: Re-adding a Previously Removed Submodule Fails

Problem: After removing a submodule, trying to add it again produces errors about cached data.

Solution: Ensure you removed .git/modules/<path> during the removal process. If you forgot:

rm -rf .git/modules/libs/shared-utils
git submodule add https://github.com/org/shared-utils.git libs/shared-utils

Pitfall 6: Submodule URL Changed Upstream

Problem: The submodule's remote repository moved to a new URL. Fetches fail with "repository not found."

Solution: Update .gitmodules and sync:

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

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

# Fetch from the new URL
git submodule update --remote libs/shared-utils

Real-World Use Cases

Use Case 1: Shared Component Library

A design system repository is included as a submodule in multiple frontend applications. Each app pins the version of the design system it is compatible with. When the design system releases a new version, each app team updates the submodule pin on their own schedule after testing.

# App repo structure
my-web-app/
├── src/
├── libs/
│   └── design-system/   # Submodule pinned to v3.2.0
└── package.json

Use Case 2: Proto/Schema Repository

Microservices that communicate via Protocol Buffers or GraphQL share a schema repository. Each service includes it as a submodule and generates code from the schemas during build.

# Each service includes the shared schemas
order-service/
├── proto/
│   └── shared-schemas/  # Submodule
├── generated/           # Built from submodule proto files
└── src/

Use Case 3: Documentation Site

A documentation website pulls in README files from multiple project repositories. Each project repo is a submodule, and the docs build system extracts markdown from each one.

Use Case 4: Vendored Fork

You depend on an open-source library that you have forked to apply custom patches. The fork is included as a submodule pinned to your patched commit. When the upstream releases a new version, you rebase your patches and update the pin.

# Vendor a forked dependency
git submodule add https://github.com/your-org/forked-lib.git vendor/forked-lib

# When upstream updates, inside the submodule:
cd vendor/forked-lib
git fetch upstream
git rebase upstream/main
git push origin main
cd ../..
git add vendor/forked-lib
git commit -m "Rebase forked-lib onto upstream v4.1.0"

Frequently Asked Questions

What is a Git submodule and when should I use one?

A Git submodule is a reference to another Git repository embedded inside a parent repository at a specific commit. The parent repo tracks which commit the submodule should point to, but the submodule's full history lives in its own repository. Use submodules when you need to include a shared library, a vendored dependency, or any project that has its own release cycle and contributors. They work best when the included project is relatively stable and updated infrequently.

How do I clone a repository that contains submodules?

Use git clone --recurse-submodules <url> to clone the parent repository and all its submodules in one step. If you already cloned without that flag, run git submodule update --init --recursive inside the repository to fetch and check out all submodules, including nested ones. The --recursive flag is important because submodules can themselves contain submodules.

How do I completely 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 the submodule directory and its entry from .gitmodules, and finally delete the leftover metadata with rm -rf .git/modules/<path>. Commit the changes afterward. Simply deleting the directory is not enough because Git stores submodule metadata in three places: .gitmodules, .git/config, and .git/modules/.

What is the difference between git submodule and git subtree?

Git submodules keep a reference (pointer) to an external repository at a specific commit, while git subtree merges the external repository's files directly into your project's tree. Submodules require everyone to run extra commands (init, update) and the external repo stays separate. Subtree is simpler for consumers since the files are just regular files in your repo, but pushing changes back upstream is more complex. Use submodules when the dependency is actively developed elsewhere; use subtree when you want a simpler workflow and rarely push changes back.

Why is my submodule showing a dirty diff or detached HEAD?

Submodules are always checked out in a detached HEAD state because the parent repo tracks a specific commit hash, not a branch. This is normal behavior. A dirty diff (modified content) appears when the submodule's working directory has uncommitted changes or when the checked-out commit differs from what the parent expects. Run git submodule update to reset the submodule to the expected commit, or cd into the submodule and commit your changes if you intend to update it.

Continue Learning

This guide is part of our Git deep-dive series. Explore the related guides to master every aspect of Git:

Related Tools

Git Diff Viewer
Visualize and compare diffs in the browser