
Rust Best Practices for Using `#[cfg]` and Feature Flags to Build Portable Code
Why conditional compilation matters
In Rust, not every target has the same capabilities. A CLI tool may need different behavior on Windows and Unix. A library may support std on desktop platforms and no_std on embedded targets. A server application may expose optional integrations only when a dependency is enabled.
Conditional compilation lets you:
- exclude unsupported code before it is compiled
- reduce binary size by removing unused paths
- keep platform-specific code isolated
- offer optional functionality without forcing every user to pay for it
The two main tools are:
#[cfg(...)]for compile-time conditions- Cargo features for opt-in capabilities
Used well, they make code more portable and easier to ship. Used poorly, they can scatter logic across the codebase and create hard-to-debug build combinations.
Prefer compile-time selection over runtime branching
A common mistake is to write runtime checks for conditions that are already known at compile time.
For example, do not do this:
fn platform_name() -> &'static str {
if cfg!(target_os = "windows") {
"windows"
} else {
"unix-like"
}
}cfg! expands to a boolean expression, so both branches must still type-check and compile. That is fine for small expressions, but it does not remove platform-specific code from the build.
Prefer #[cfg] when the code itself differs:
#[cfg(target_os = "windows")]
fn platform_name() -> &'static str {
"windows"
}
#[cfg(not(target_os = "windows"))]
fn platform_name() -> &'static str {
"unix-like"
}This approach is better when the implementation depends on platform APIs, file paths, socket behavior, or system calls that do not exist everywhere.
Rule of thumb
Use cfg! for tiny expression-level decisions. Use #[cfg] when entire items, modules, or implementations differ.
Keep platform-specific code behind small boundaries
The best way to manage conditional compilation is to isolate it.
Instead of sprinkling #[cfg] across business logic, create a narrow abstraction layer:
pub trait TempDirProvider {
fn create_temp_dir(&self) -> std::io::Result<std::path::PathBuf>;
}
pub struct DefaultTempDirProvider;
impl TempDirProvider for DefaultTempDirProvider {
fn create_temp_dir(&self) -> std::io::Result<std::path::PathBuf> {
platform::create_temp_dir()
}
}
mod platform {
#[cfg(target_os = "windows")]
pub fn create_temp_dir() -> std::io::Result<std::path::PathBuf> {
std::env::temp_dir().join("myapp")
.try_exists()
.and_then(|_| Ok(std::env::temp_dir().join("myapp")))
}
#[cfg(not(target_os = "windows"))]
pub fn create_temp_dir() -> std::io::Result<std::path::PathBuf> {
Ok(std::env::temp_dir().join("myapp"))
}
}The exact implementation is less important than the structure: the rest of the application depends on a stable interface, while platform differences stay inside one module.
This pattern pays off when you later add macOS-specific behavior, a no_std implementation, or a mock for tests.
Use Cargo features for optional capabilities
Features are the right tool when the variation is not about the target platform, but about what the user wants to include.
Examples:
- enable TLS support
- include JSON serialization
- add a database backend
- switch on logging or tracing integration
A feature should represent a coherent capability, not a single line of code.
Example Cargo.toml
[package]
name = "portable-client"
version = "0.1.0"
edition = "2021"
[features]
default = ["json"]
json = ["dep:serde", "dep:serde_json"]
tls = ["dep:rustls"]
[dependencies]
serde = { version = "1", optional = true, features = ["derive"] }
serde_json = { version = "1", optional = true }
rustls = { version = "0.23", optional = true }Then gate code with #[cfg(feature = "json")]:
#[cfg(feature = "json")]
pub fn encode_message<T: serde::Serialize>(value: &T) -> serde_json::Result<String> {
serde_json::to_string(value)
}
#[cfg(not(feature = "json"))]
pub fn encode_message<T>(_value: &T) -> Result<String, &'static str> {
Err("json feature disabled")
}This makes the build matrix explicit. Users can choose the functionality they need without pulling in unnecessary dependencies.
Understand the difference between #[cfg], cfg!, and #[cfg_attr]
These three tools are related but serve different purposes.
| Tool | Best use | Effect |
|---|---|---|
#[cfg(...)] | Include or exclude items | Removes code from the compilation unit |
cfg!(...) | Small expression-level checks | Produces a boolean; both branches still compile |
#[cfg_attr(...)] | Conditionally apply attributes | Adds attributes only when the condition is true |
#[cfg_attr] in practice
A common use is enabling derives only for certain builds:
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Config {
pub host: String,
pub port: u16,
}This keeps the type definition clean while avoiding duplicate structs.
Another useful case is conditional linting or documentation attributes:
#[cfg_attr(doc, doc = "This function is available on supported targets.")]
pub fn connect() {}Use #[cfg_attr] when the item exists in all builds, but its metadata should vary.
Avoid feature combinations that create “impossible” states
A feature flag system becomes fragile when combinations are not well-defined.
For example, if postgres and sqlite are both optional backends, decide whether:
- both can be enabled together
- exactly one must be selected
- one is the default and the other is an override
Do not leave that ambiguity to downstream users.
Good practice: validate feature combinations
#[cfg(all(feature = "postgres", feature = "sqlite"))]
compile_error!("Enable only one database backend: postgres or sqlite");This fails fast at compile time with a clear message.
You can also use #[cfg] to select a backend implementation:
#[cfg(feature = "postgres")]
mod db {
pub fn connect() -> &'static str {
"postgres"
}
}
#[cfg(feature = "sqlite")]
mod db {
pub fn connect() -> &'static str {
"sqlite"
}
}If neither backend should be allowed, make that explicit too. Silent fallback behavior is often a source of production bugs.
Design for no_std from the start if portability matters
If your library may be used in embedded or constrained environments, plan for no_std early. Retrofitting later is possible, but painful.
A common pattern is:
#![cfg_attr(not(feature = "std"), no_std)]
#[cfg(feature = "std")]
extern crate std;Then structure your code so the core logic depends only on core and alloc where possible.
Practical guidance
- keep parsing, validation, and pure logic in
core-friendly modules - isolate file I/O, networking, and threading behind
std-gated modules - use
alloconly when heap allocation is truly needed - avoid leaking
stdtypes into public APIs unless the feature requires them
If a type or function is only available with std, gate it clearly:
#[cfg(feature = "std")]
pub fn read_config(path: &std::path::Path) -> std::io::Result<String> {
std::fs::read_to_string(path)
}This makes the portability boundary obvious to users and maintainers.
Prefer module-level gating over scattered item-level gates
A few #[cfg] attributes are fine. Dozens scattered across a file are not.
Compare these approaches:
Hard to maintain
#[cfg(target_os = "linux")]
pub fn open_socket() {}
#[cfg(target_os = "linux")]
pub fn close_socket() {}
#[cfg(target_os = "linux")]
pub fn socket_name() -> &'static str { "linux" }Easier to maintain
#[cfg(target_os = "linux")]
mod platform {
pub fn open_socket() {}
pub fn close_socket() {}
pub fn socket_name() -> &'static str { "linux" }
}
#[cfg(not(target_os = "linux"))]
mod platform {
pub fn open_socket() {}
pub fn close_socket() {}
pub fn socket_name() -> &'static str { "other" }
}The second version localizes the platform split. It also makes it easier to add tests, documentation, and future platform variants.
When the differences are substantial, consider separate files:
#[cfg(target_os = "windows")]
mod platform_windows;
#[cfg(target_os = "unix")]
mod platform_unix;This keeps each implementation focused and reduces accidental cross-contamination.
Test every supported configuration
Conditional compilation is only safe if you test the combinations you ship.
At minimum, run CI against:
- each supported target OS
- each major feature set
stdandno_stdbuilds if applicable
A practical matrix might look like this:
| Build | Purpose |
|---|---|
| default features | verify the common path |
--no-default-features | ensure optional dependencies are truly optional |
--features tls | validate security-related code paths |
| target-specific build | catch platform API assumptions |
For example:
cargo test
cargo test --no-default-features
cargo test --features tls
cargo test --target x86_64-pc-windows-msvcIf a feature changes public behavior, add tests for both enabled and disabled states. This is especially important for serialization, parsing, and protocol compatibility.
Document the configuration surface clearly
Every feature and platform condition is part of your API, even if it is compile-time only.
Document:
- which targets are supported
- which features are stable
- whether features are additive or mutually exclusive
- what happens when a feature is disabled
A short README table is often enough:
| Feature | Default | Effect |
|---|---|---|
json | yes | Enables JSON encoding and decoding |
tls | no | Adds TLS transport support |
std | yes | Uses the standard library |
This helps users choose the right build and prevents confusion when a symbol is missing due to a disabled feature.
Common mistakes to avoid
1. Using features as runtime flags
Features are compile-time only. If you need to toggle behavior after deployment, use configuration files, environment variables, or command-line options.
2. Exposing too many tiny features
A feature per function leads to combinatorial complexity. Prefer coarse-grained capabilities.
3. Duplicating logic across cfg branches
If two branches are 90% identical, factor out the shared code and keep only the platform-specific part conditional.
4. Forgetting dependency features
If your code depends on an optional crate feature, make sure your own feature enables it in Cargo.toml. Otherwise, users will enable your feature and still get missing items.
5. Letting unsupported combinations compile silently
Use compile_error! when a combination is invalid. Failing early is much better than producing a broken binary.
A practical checklist
Before merging conditional compilation changes, verify:
- the condition is compile-time appropriate
- platform-specific code is isolated in a module or file
- feature names describe capabilities, not implementation details
- invalid feature combinations are rejected explicitly
- tests cover the enabled and disabled paths
- documentation explains the supported configuration surface
If you can answer those points confidently, your code is likely to remain portable and maintainable.
