The binary needs to carry its own data
You are shipping a command-line tool. It needs a default configuration template. You write code to read config.toml from the disk. It works on your machine. You zip the binary and send it to a friend. They run it. The tool crashes because the file isn't there. You forgot that the binary and the file are separate things. You want the file to live inside the binary so the user gets one file that just works.
Rust gives you include_str! and include_bytes!. These macros solve the bundling problem by reading files during compilation. The file content becomes part of the executable. No external files required. No runtime disk access. The data is there when the program starts.
Compile-time inclusion
Think of baking a cake. Reading a file at runtime is like bringing flour and sugar to a party and mixing them in the kitchen there. You need the ingredients, you need a kitchen, and you need time to mix. include_str! is like baking the cake at your house, putting it in a box, and carrying the box to the party. The cake is already made. It is inside the box. When you open the box, the cake is there. No mixing required.
The macro runs when rustc compiles your code. It reads the file, checks its contents, and embeds the bytes directly into the binary. At runtime, your code just points to that memory. The file is part of the program.
/// Reads the file when the compiler runs, not when main() runs.
/// The content becomes a string literal in the binary.
/// The path is relative to this source file.
const DEFAULT_CONFIG: &str = include_str!("config.txt");
fn main() {
// No file I/O happens here. The data is already in memory.
// Access is instant. No error handling needed for missing files.
println!("{}", DEFAULT_CONFIG);
}
The convention is to use include_str! for text files and include_bytes! for binary files. If the file is text, include_str! validates UTF-8 encoding at compile time. You get a &str that is guaranteed to be valid. If you use include_bytes! on a text file, you get &[u8]. You have to validate UTF-8 yourself or risk panics later. The compiler helps you avoid encoding bugs when you pick the right macro.
How the compiler handles it
When the compiler sees include_str!("config.txt"), it expands the macro before generating code. It looks for config.txt relative to the source file containing the macro. If you are in src/lib.rs, it looks in src/config.txt. It reads the bytes. It creates a string literal with those bytes. It embeds that literal into the compiled binary.
The result is a &'static str. The lifetime is 'static. The data lives for the entire duration of the program. You can pass this reference anywhere. You can return it from functions without lifetime annotations. The borrow checker knows the data never goes away. This makes embedded data incredibly easy to work with. You don't need to manage ownership. The binary owns the data forever.
Cargo tracks the file dependency automatically. If you change config.txt and run cargo build, Cargo detects the change and recompiles the crate. You don't need to touch the source file. The macro tells Cargo about the dependency. This keeps your workflow smooth. Edit the file, rebuild, and the binary updates.
The data lands in the read-only data section of the executable. On Linux, this is .rodata. The operating system maps this section into memory when the program starts. It is shared across processes if the OS supports it. This is efficient. Multiple instances of your program can share the same memory pages for the embedded data.
Real-world usage
Embedded data shines when you need small, static assets. Configuration templates, HTML pages for a web server, shader code for a game, or TLS certificates are common use cases. Here is a realistic example of a simple web server serving an embedded HTML page.
/// Embeds the HTML template.
/// This runs at compile time.
/// The HTML is now part of the binary's read-only data section.
const INDEX_HTML: &str = include_str!("templates/index.html");
/// Returns the home page content.
/// No Result needed because the data is guaranteed to be there.
/// The lifetime is 'static, so this reference is safe to return.
fn get_home_page() -> &'static str {
INDEX_HTML
}
fn main() {
// Access is instant. No I/O latency.
// The data is already in memory.
let page = get_home_page();
println!("Serving {} bytes of HTML", page.len());
// You can pass this reference to any function.
// No cloning required. No lifetime parameters needed.
serve_page(page);
}
fn serve_page(content: &str) {
// Simulate sending the page.
println!("Sending page...");
}
The function get_home_page returns &'static str. The caller gets a reference that never expires. You don't need to clone the string. You don't need to worry about the data being dropped. The reference is safe to store, pass around, and return. This simplifies API design significantly. Functions that return embedded data have clean signatures.
Pitfalls and gotchas
Path resolution trips up everyone eventually. The path is relative to the source file, not the project root. If src/main.rs includes assets/logo.png, the file must be at src/assets/logo.png. Put the file in the project root and the include fails. The compiler rejects missing files immediately. You won't get a runtime panic. You get a compile error. The build stops. Check the file location relative to the source file before blaming the macro.
Large files bloat your binary. Embedding a 50MB video makes your executable 50MB larger. Use these macros for small assets. Configs, shaders, templates, and certificates are usually kilobytes or a few megabytes. Datasets and media files belong on disk. Read them at runtime. Keep your binary lean.
Changing the file requires recompilation. The macro captures the content at compile time. Edit the file, run cargo build again. The binary updates. If you are developing with cargo watch, the watcher rebuilds when the file changes. This keeps the loop tight. Just remember that the binary is stale until you rebuild.
Mutation is impossible. The result is immutable. You cannot change the string or byte slice. If you try to mutate the content, the compiler rejects you.
// This compiles, but s is a mutable binding to an immutable reference.
let mut s = include_str!("data.txt");
// This fails. You cannot mutate the string content.
// The compiler rejects this with E0599 (no method named push_str found).
// s.push_str("more");
// This also fails if s were not declared mut.
// The compiler rejects this with E0596 (cannot assign twice to immutable variable).
// s = "new value";
The data is read-only. If you need mutable data, clone it into a String or Vec<u8>. The clone happens at runtime. The embedded data stays immutable. This is a safety feature. Embedded data is shared across the program. Allowing mutation would cause data races. The compiler prevents this by design.
Build scripts add a layer of complexity. If you generate the file in build.rs, you need to tell Cargo to recompile when the file changes. Use println!("cargo:rerun-if-changed=generated.txt") in your build script. Otherwise, you change the input, run build, and get the old output. Cargo thinks nothing changed. The rerun directive fixes this. It tells Cargo to watch the file and rebuild the crate when it updates.
// build.rs
fn main() {
// Generate a file at compile time.
std::fs::write("src/generated.txt", "Hello from build script").unwrap();
// Tell Cargo to recompile if this file changes.
// Without this, changes to generated.txt won't trigger a rebuild.
println!("cargo:rerun-if-changed=src/generated.txt");
}
The include! macro is different. It includes Rust source code, not data files. Use include! to paste code from another file. Use include_str! and include_bytes! for data. Don't confuse them. include! is for code generation and splitting large modules. The data macros are for assets.
When to embed versus read at runtime
Use include_str! when you need text data embedded in the binary and want compile-time UTF-8 validation. The macro reads the file and produces a &str. The compiler checks the encoding. You get a string slice with no runtime overhead. The data is available instantly when the program starts.
Use include_bytes! when you need binary data like images, certificates, or compiled shaders. The macro produces a &[u8]. You handle the bytes directly. The compiler doesn't check encoding. You have full control over the raw data. This is ideal for assets that aren't text.
Use runtime file reading when the data changes after the binary is built. Users might update a config file. The binary shouldn't need recompilation. Read the file in main() or on demand. Use std::fs::read_to_string or std::fs::read. Handle errors gracefully. The file might be missing or corrupted. Runtime reading gives you flexibility. Embedded data gives you simplicity.
Use include! when you want to paste Rust source code into another file. This is for code generation or splitting large modules. You are including code, not data. The included file must be valid Rust. The compiler parses and type-checks it. This is a different tool for a different job.
Embed small, static assets. Read large, dynamic files at runtime. The binary size matters. Keep the executable lean. Recompile to update embedded data. Accept the trade-off. You gain simplicity and reliability. You lose the ability to update data without rebuilding. Choose the approach that fits your use case.