Cargo: Create Projects, Manage Dependencies, and Organize Workspaces

1. Overview

Cargo is Rust’s official build tool and package manager. It creates projects, loads dependencies, compiles, runs tests, and manages multiple crates in the same repository.

This article walks through three things you do daily with Cargo. First, creating binary and library projects. Second, adding and configuring dependencies. Third, organizing a workspace of multiple crates that share configuration. After reading, you’ll be able to build a multi-module Rust project without copying dependency versions everywhere.

2. Creating a New Project

2.1. Binary and Library

The cargo new command creates a new crate. By default, it’s a binary crate—a program with a main function:

cargo new order-service
cd order-service

The directory structure created:

order-service/
├── Cargo.toml
├── .gitignore
└── src/
    └── main.rs

If you want to write a library for other crates to use, add the --lib flag:

cargo new pricing --lib

In this case, Cargo creates src/lib.rs instead of src/main.rs. A crate can have both files. Then main.rs calls the logic in lib.rs using the crate name.

2.2. Cargo.toml File

Cargo.toml is the manifest for a crate:

[package]
name = "order-service"
version = "0.1.0"
edition = "2024"

[dependencies]
  • name: the crate name. Hyphens (-) in the name become underscores (_) when used in code, e.g., order_service.
  • version: version following Semantic Versioning.
  • edition: the language edition. Edition 2024 is available since Rust 1.85, and cargo new uses it as the default on the current toolchain.
  • [dependencies]: the list of external crates, currently empty.

2.3. Basic Build Commands

cargo check        # check for compilation errors, don't generate executable
cargo build        # build debug version to target/debug
cargo run          # build then run
cargo build --release   # build optimized to target/release

While writing code, use cargo check. This command is much faster than cargo build because it skips code generation. Only run --release when benchmarking or building for deployment. Debug builds can be dozens of times slower than release builds.

3. Adding Dependencies

3.1. Using cargo add

Suppose you need to serialize an Order to JSON. Two common crates for this are serde and serde_json:

cargo add serde --features derive
cargo add serde_json

Cargo automatically updates Cargo.toml:

[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

You can edit this file by hand. cargo add just helps you avoid typos and automatically picks the latest compatible version.

3.2. What Version Requirements Mean

The string "1.0" does not mean exactly version 1.0.0. Cargo interprets it as ^1.0, meaning any version >= 1.0.0 and < 2.0.0. Here are some other ways to write it:

[dependencies]
regex = "1.10"          # >= 1.10.0, < 2.0.0
chrono = "=0.4.38"      # exactly 0.4.38
rand = "~0.8.5"         # >= 0.8.5, < 0.9.0
uuid = ">=1.4, <1.9"    # custom range

For 0.x versions, Cargo treats the second number as the “major” part. So "0.4" only allows 0.4.* and won’t bump to 0.5.

The actual versions used are in Cargo.lock. Cargo writes this file on the first build. Subsequent builds use exactly those versions until you run:

cargo update              # update all dependencies within allowed ranges
cargo update -p serde     # update only serde

3.3. Using Dependencies in Code

Edit src/main.rs:

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
struct Order {
    id: u64,
    customer: String,
    total_cents: u64,
}

fn main() {
    let order = Order {
        id: 42,
        customer: "An".to_string(),
        total_cents: 150_000,
    };

    let json = serde_json::to_string(&order).expect("serialize Order");
    println!("{json}");

    let parsed: Order = serde_json::from_str(&json).expect("deserialize Order");
    println!("{parsed:?}");
}
  • #[derive(Serialize, Deserialize)] only works when the derive feature of serde is enabled. Without this feature, you’ll get a “macro not found” error.
  • serde_json::to_string converts the struct to a JSON string.
  • serde_json::from_str does the reverse. The target type is inferred from the Order annotation.

Run cargo run:

{"id":42,"customer":"An","total_cents":150000}
Order { id: 42, customer: "An", total_cents: 150000 }

3.4. Other Dependency Types

Besides [dependencies], Cargo has two other commonly used sections:

[dev-dependencies]
pretty_assertions = "1.4"   # only used when running tests, examples, benchmarks

[build-dependencies]
cc = "1.0"                  # only used for build.rs

Crates in [dev-dependencies] don’t get pulled into the final binary and won’t affect users of your library. Put libraries that only serve testing here.

You can also point dependencies to a local directory or Git:

[dependencies]
pricing = { path = "../pricing" }
payment-sdk = { git = "https://example.com/payment-sdk.git", tag = "v2.1.0" }

4. Organizing Workspaces

As your project grows, you’ll want to split it into multiple crates. For example, a domain crate containing business logic, and an api crate as the running program. A workspace lets these crates share Cargo.lock, the target/ directory, and dependency versions.

4.1. Creating a Root Workspace

mkdir shop && cd shop

Create shop/Cargo.toml:

[workspace]
resolver = "3"
members = ["crates/*"]

[workspace.package]
version = "0.1.0"
edition = "2024"

[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
  • This file has no [package], so it’s a virtual manifest. It only serves to group child crates.
  • resolver = "3" must be declared explicitly in a virtual manifest. Without a [package], Cargo can’t infer the resolver from the edition, and will warn if you omit this line.
  • members = ["crates/*"] groups all crates in the crates/ directory.
  • [workspace.package] and [workspace.dependencies] are shared values that child crates inherit.

Create two child crates:

cargo new crates/domain --lib
cargo new crates/api

The resulting structure:

shop/
├── Cargo.toml
├── Cargo.lock
├── target/
└── crates/
    ├── domain/
    │   ├── Cargo.toml
    │   └── src/lib.rs
    └── api/
        ├── Cargo.toml
        └── src/main.rs

4.2. Inheriting Configuration from the Workspace

crates/domain/Cargo.toml:

[package]
name = "domain"
version.workspace = true
edition.workspace = true

[dependencies]
serde.workspace = true

crates/api/Cargo.toml:

[package]
name = "api"
version.workspace = true
edition.workspace = true

[dependencies]
domain = { path = "../domain" }
serde_json.workspace = true

serde.workspace = true means this crate gets its version and features from [workspace.dependencies]. When you need to upgrade serde, you only change it in one place in the root file.

Child crates can still enable additional features:

[dependencies]
serde = { workspace = true, features = ["rc"] }

Features here are added to the ones declared in the workspace, not replacing them.

4.3. Code in the domain Crate

crates/domain/src/lib.rs:

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrderLine {
    pub product: String,
    pub unit_price_cents: u64,
    pub quantity: u32,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Order {
    pub id: u64,
    pub customer: String,
    pub lines: Vec<OrderLine>,
}

impl Order {
    pub fn total_cents(&self) -> u64 {
        self.lines
            .iter()
            .map(|line| line.unit_price_cents * u64::from(line.quantity))
            .sum()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn line(product: &str, price: u64, qty: u32) -> OrderLine {
        OrderLine {
            product: product.to_string(),
            unit_price_cents: price,
            quantity: qty,
        }
    }

    #[test]
    fn total_sums_all_lines() {
        let order = Order {
            id: 1,
            customer: "Binh".to_string(),
            lines: vec![line("Coffee", 35_000, 2), line("Cake", 25_000, 1)],
        };

        assert_eq!(order.total_cents(), 95_000);
    }

    #[test]
    fn empty_order_has_zero_total() {
        let order = Order { id: 2, customer: "Chi".to_string(), lines: vec![] };

        assert_eq!(order.total_cents(), 0);
    }
}
  • pub on structs and fields lets the api crate access them.
  • u64::from(line.quantity) converts u32 to u64 without data loss.
  • #[cfg(test)] ensures the test module is only compiled when running cargo test.

Run tests for just the domain crate:

cargo test -p domain
running 2 tests
test tests::empty_order_has_zero_total ... ok
test tests::total_sums_all_lines ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Running cargo test from the root directory runs tests for all crates in the workspace.

4.4. Using domain from api

crates/api/src/main.rs:

use domain::{Order, OrderLine};

fn main() {
    let order = Order {
        id: 7,
        customer: "Dung".to_string(),
        lines: vec![OrderLine {
            product: "Tea".to_string(),
            unit_price_cents: 20_000,
            quantity: 3,
        }],
    };

    println!("Total: {}", order.total_cents());
    println!("{}", serde_json::to_string_pretty(&order).expect("serialize"));
}

From the root directory:

cargo run -p api
Total: 60000
{
  "id": 7,
  "customer": "Dung",
  "lines": [
    {
      "product": "Tea",
      "unit_price_cents": 20000,
      "quantity": 3
    }
  ]
}

The api crate doesn’t declare serde, but it can still serialize Order. That’s because the Serialize trait is already implemented inside the domain crate. api only needs serde_json to call the conversion function.

4.5. Useful Commands in a Workspace

cargo build --workspace     # build all crates
cargo check -p api          # check only the api crate and its dependencies
cargo tree -p api           # view the dependency tree
cargo tree -d               # list crates with duplicate versions

cargo tree -d is very useful when your binary grows unexpectedly large. The output often shows two versions of the same crate being pulled in.

5. Common Mistakes

Note: Features in Cargo are unified. Suppose crate A enables tokio/full and crate B only needs tokio/rt. When building together in a workspace, B also gets the full set of features. Code that works in a workspace might fail when you build B standalone. Run cargo check -p <crate-name> for each crate in CI.

  • Forgetting to enable a feature. Errors like “cannot find derive macro Serialize” are almost always due to missing features = ["derive"].
  • Not committing Cargo.lock. Current Cargo guidance recommends committing this file for both binaries and libraries. This way, CI builds are reproducible across runs.
  • Path dependencies when publishing. domain = { path = "../domain" } won’t publish to crates.io without a version. To publish, declare both: { path = "../domain", version = "0.1.0" }.
  • Nested workspaces. A crate can only belong to one workspace. If you run cargo new inside a subdirectory of another workspace, Cargo will error or automatically add the crate to members. Double-check the root file after creating.
  • Overusing workspaces. For a small program, you only need to split modules within a single crate (mod order;). Splitting into separate crates increases the number of manifests and configuration overhead. Use workspaces when parts truly have clear boundaries, need separate builds or tests, or need reuse.

6. Conclusion

This article covered three essential things with Cargo:

  • Create binary or library projects with cargo new, and use cargo check for fast feedback.
  • Add dependencies with cargo add, understand how version requirements and Cargo.lock determine actual versions, and separate dev-dependencies from main dependencies.
  • Group multiple crates in a workspace, declare shared versions in [workspace.dependencies], and inherit using workspace = true.

As your project grows, keep one simple rule: all dependency versions live in the root file, and each crate only declares what it actually uses. This makes upgrading and controlling binary size much easier.

rustcargodependencyworkspacebuild-tool