Git submodules let you embed one Git repository inside another.

Let’s see it in action. Imagine you have a main project, my-app, and you want to include a shared library, shared-utils, which is its own Git repository.

First, in your my-app directory, you add the shared-utils repository as a submodule. You’ll run this command from the root of your my-app repository:

git submodule add https://github.com/your-username/shared-utils.git libs/shared-utils

This command does a few things:

  1. It clones the shared-utils repository into a new directory named libs/shared-utils within your my-app project.

  2. It creates a .gitmodules file in the root of my-app (or updates it if it already exists). This file tracks which submodules are part of your project and where they are located. It will look something like this:

    [submodule "libs/shared-utils"]
        path = libs/shared-utils
        url = https://github.com/your-username/shared-utils.git
    
  3. It stages both the new .gitmodules file and a special entry for the libs/shared-utils directory in your my-app repository. This special entry doesn’t record the contents of the submodule, but rather a specific commit hash from the shared-utils repository.

Now, when you commit these changes in my-app:

git commit -m "Add shared-utils submodule"

Your my-app repository now knows about shared-utils and, crucially, which version (commit) of shared-utils it should be using. This is a key difference from just copying files: you’re pinning a specific commit.

When someone else clones my-app, they won’t automatically get the contents of shared-utils. They’ll see the libs/shared-utils directory, but it will be empty. To initialize and clone the submodule contents, they need to run:

git submodule update --init --recursive

The --init flag tells Git to register the submodules defined in .gitmodules for the current repository. The --recursive flag is important if your submodule itself has submodules.

Now, the libs/shared-utils directory will be populated with the files from the specified commit of the shared-utils repository.

To update the submodule to a newer commit in shared-utils, you first need to navigate into the submodule’s directory and pull the latest changes from its remote:

cd libs/shared-utils
git pull origin main # Or whatever branch you want to track
cd .. # Go back to my-app's root

After pulling the latest changes in the submodule, you’ll notice that my-app now sees a change in the libs/shared-utils directory:

git status

This status output will show modified: libs/shared-utils (new commits). This means my-app’s record of the submodule has been updated to point to the new commit hash you just pulled. You need to commit this change in my-app:

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

This is how you manage versions: the main repository (my-app) always points to a specific commit hash of the submodule (shared-utils). This ensures that if you check out an older commit of my-app, Git can automatically check out the corresponding, older commit of shared-utils that was used at that time, provided you run git submodule update --recursive after checking out the old my-app commit.

A common pitfall is forgetting to git add and git commit the submodule directory after updating its contents. Without this commit in the parent repository, the parent repository still points to the old commit hash, and git submodule update will happily revert the submodule back to that old commit on another clone or checkout.

The most surprising thing about submodules is that the main repository doesn’t actually store the submodule’s content; it only stores a pointer to a specific commit hash in the submodule’s repository. This means that when you clone the parent repository without initializing the submodules, the submodule directory is present but empty.

When you need to make changes to the shared library and push them back, you first make the changes within the libs/shared-utils directory, commit them inside the shared-utils repository (e.g., cd libs/shared-utils && git commit -am "Fix bug in util" && cd ..), and then you go back to the my-app repository, stage the updated submodule reference (git add libs/shared-utils), and commit that change in my-app.

The next thing you’ll likely encounter is managing detached HEAD states within submodules or dealing with submodules that are in a divergent state from their remote.

Want structured learning?

Take the full Git course →