When one source isn't enough
You're building a CLI tool. You need random numbers, so you grab rand. Your team has a shared config parser in a sibling folder. You found a cool experimental parser on GitHub that hasn't hit crates.io yet. Rust doesn't make you choose one source. Cargo handles all three, but you have to tell it where to look.
Dependencies in Rust come from three places: the registry, local paths, and Git repositories. The registry is the standard supermarket for Rust code. Paths are for local development where you're editing the code right now. Git is for unreleased work or crates that live exclusively on version control platforms. Cargo unifies these sources into a single dependency graph. You declare what you need, and Cargo fetches, compiles, and links everything.
How dependency sources map to real life
Think of dependencies as ingredients for a recipe. The registry is the supermarket: standardized, reliable, versioned, and available to everyone. Path dependencies are like borrowing sugar from the neighbor: instant, no versioning, and changes in their kitchen immediately affect your baking. Git dependencies are like ordering from a specialty farm: you get the latest harvest, but you might receive a batch that hasn't been packaged for retail yet.
Cargo manages the supply chain. It resolves versions, downloads artifacts, compiles crates, and links them into your binary. The source you pick changes how Cargo resolves versions and how often your build changes. Registry dependencies are stable. Path dependencies change every time you save a file in the sibling folder. Git dependencies change when the remote branch moves, unless you pin them.
Registry dependencies: the default
Registry dependencies point to crates.io, the official Rust package registry. This is where 99% of your dependencies will live. You specify a crate name and a version requirement. Cargo downloads the crate, checks its checksum, and compiles it.
# Cargo.toml
[dependencies]
# Fetches rand version 0.8.x from crates.io.
# The caret (^) is implicit, allowing minor updates.
rand = "0.8"
# You can request specific features.
# This enables the derive macro for serde.
serde = { version = "1.0", features = ["derive"] }
// src/main.rs
use rand::Rng;
fn main() {
// Generates a random u32 using the rand crate.
let n = rand::random::<u32>();
println!("{n}");
}
When you run cargo build, Cargo reads Cargo.toml and checks Cargo.lock. The lock file records the exact versions of every dependency. If the lock file exists and matches your requirements, Cargo uses those exact versions. This ensures reproducible builds. If the lock file is missing or outdated, Cargo resolves versions again. It downloads the crates, compiles them, and updates the lock file.
Registry dependencies support semver. version = "1.0" means >=1.0.0, <2.0.0. Cargo will pick the highest compatible version. This allows you to get bug fixes and features without breaking changes. You can tighten the requirement with version = "=1.0.2" to pin an exact version, but this is rare. Pinning blocks patch updates that might contain security fixes.
Trust crates.io. The ecosystem runs on it.
Path dependencies: local development
Path dependencies point to a directory on your filesystem. Cargo treats the directory as a crate root. It compiles the code directly from the path. There is no version resolution. There is no download. Changes to the source code take effect immediately on the next build.
# Cargo.toml
[dependencies]
# Points to a local directory relative to this Cargo.toml.
# No version is checked. Cargo compiles the code as-is.
my_utils = { path = "../my_utils" }
Path dependencies are essential for workspace development. A workspace groups multiple crates that are developed together. You can have a library and a binary in the same workspace. The binary depends on the library via path. When you edit the library, the binary picks up the changes instantly.
# Cargo.toml (workspace root)
[workspace]
members = ["lib", "bin"]
# bin/Cargo.toml
[dependencies]
# Uses the library from the workspace.
# The path is relative to this Cargo.toml.
my_lib = { path = "../lib" }
Path dependencies bypass the registry entirely. If my_utils defines a type Config, your crate sees that type. If another crate in your dependency graph also depends on my_utils via the registry, Cargo sees them as different crates. This causes type mismatches. The compiler will reject code that tries to pass my_utils::Config from the path to a function expecting my_utils::Config from the registry. You'll see E0308 (mismatched types) because the types are distinct, even though they have the same name.
Path dependencies are for development, not production. Switch back to registry dependencies before you ship.
Git dependencies: unreleased code
Git dependencies fetch code from a Git repository. You can specify a branch, a tag, or a specific commit revision. Cargo clones the repository, checks out the reference, and compiles the crate. This is useful when you need a fix that hasn't been released yet, or when a crate is only available on GitHub.
# Cargo.toml
[dependencies]
# Fetches from a git repo.
# Pin to a tag for stability. Tags don't move.
experimental_parser = { git = "https://github.com/example/parser", tag = "v0.1.0" }
# You can also use a branch, but branches move.
# Your build might break if the maintainer pushes a breaking change.
# alpha_tool = { git = "https://github.com/example/tool", branch = "main" }
# For maximum stability, pin to a commit revision.
# This never changes unless you update the hash.
# stable_dep = { git = "https://github.com/example/dep", rev = "a1b2c3d" }
Git dependencies support the same feature syntax as registry dependencies. You can request features, disable default features, and mark dependencies as optional. The main difference is the source. Cargo treats the Git repository as a crate source. It resolves the reference, downloads the code, and compiles it.
Pinning to a tag or revision is critical. Branches move. If you depend on branch = "main", your build depends on whatever is at the tip of main. A maintainer might push a commit that breaks the API. Your CI pipeline will fail unexpectedly. Tags and revisions are immutable. They provide a stable reference point.
Pin your Git deps to a tag. Branches move, and your build shouldn't break because someone pushed a commit.
The patch section: overriding dependencies
Sometimes you need to replace a dependency across your entire project. Maybe a crate has a bug, and you've forked it to fix the issue. Maybe you're testing a local change to a deep dependency. The [patch] section lets you override dependencies globally.
# Cargo.toml
[patch.crates-io]
# Overrides any dependency on `serde` with a local path.
# This applies to this crate and all its transitive dependencies.
serde = { path = "../serde-fork" }
# You can also patch with a git repo.
# broken_crate = { git = "https://github.com/me/broken-crate-fix" }
The [patch] section replaces the dependency in the entire dependency graph. If your crate depends on serde, and rand depends on serde, both will use the patched version. This is powerful for testing fixes. You can patch a dependency to a local path, verify the fix works, and then submit a PR upstream.
Patching is temporary. It's a development tool. Don't ship with patches in your Cargo.toml. Once the upstream crate releases the fix, remove the patch and update the version requirement.
Patches are a surgical tool. Use them to test fixes, not to maintain forks.
Pitfalls and compiler errors
Mixing dependency sources can cause subtle issues. The most common problem is duplicate crates. If you depend on foo via path, and another crate depends on foo via registry, Cargo sees two different foo crates. Your code might compile, but type checks will fail when you try to share types across the boundary.
error[E0308]: mismatched types
--> src/main.rs:10:12
|
10 | func(my_lib::Config);
| ^^^^^^^^^^^^ expected `registry_foo::Config`, found `path_foo::Config`
The compiler treats them as distinct types. You can't pass a path type to a function expecting a registry type. The solution is to use the same source for all dependencies on that crate. Use [patch] to force the path version everywhere, or switch to the registry version.
Another pitfall is missing features. If a dependency requires a feature, and you don't enable it, you'll get compile errors. The error might look like a missing type or trait.
error[E0432]: unresolved import `serde::Deserialize`
--> src/lib.rs:2:5
|
2 | use serde::Deserialize;
| ^^^^^^^^^^^^^^^^^^ no `Deserialize` in the root
This happens when serde is included without the derive feature. The fix is to add features = ["derive"] to the dependency declaration. Check the crate's documentation for required features.
Path dependencies can also cause reproducibility issues. If you commit a Cargo.toml with a path dependency, other developers need the sibling folder to exist. CI pipelines might fail if the path isn't mounted. Path dependencies should be isolated to local development or workspace setups where the structure is guaranteed.
Git dependencies can fail if the repository is private or requires authentication. Cargo uses the same credentials as Git. If you need to access a private repo, configure Git credentials or use a token. Cargo doesn't have a built-in auth mechanism for dependencies. It relies on the underlying Git client.
Trust the borrow checker. It usually has a point.
Decision: when to use each source
Use registry dependencies for production code and stable libraries. This is the standard for almost all Rust projects. Registry dependencies are versioned, cached, and reproducible. They integrate seamlessly with the ecosystem.
Use path dependencies when you are developing a workspace or testing a local modification. Path dependencies provide instant feedback. Changes to the source code are reflected immediately. They are essential for multi-crate projects where crates depend on each other.
Use Git dependencies when you need unreleased code or a crate that lives only on GitHub. Git dependencies give you access to the latest work. Pin to a tag or revision to ensure stability. Avoid branches in production.
Use the [patch] section to temporarily override a dependency across your entire project tree. Patches are for testing fixes and debugging. Remove patches once the upstream issue is resolved.