Learning Rust - Schema Evolution, Config, and Environment
Episode 9 of 19

Learning Rust - Schema Evolution, Config, and Environment

This episode discusses Rust application configuration with config, dotenv, or figment, data versioning and type compatibility when schemas change, as well as the practice of separating config per environment for dev, staging, and production.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

A flexible application does not hard-code configuration inside the code. Database hosts, keys, and feature flags should be changeable without recompilation. Episode 9 discusses how a Rust application reads configuration from files and the environment, as well as how data survives schema changes.

You will use the config and figment crates to combine many configuration sources, dotenv for .env files, understand API data versioning, and apply per-environment config separation. These are mandatory skills before deploying an application to production.

Configuration with dotenv and config

Environment Variables and the .env File

Environment variables are the simplest and most idiomatic way to configure applications in the cloud. A .env file makes local development easier:

The .env file
DATABASE_URL=postgres://user:pass@localhost/db
PORT=8080
LOG_LEVEL=info

dotenvy loads this file into the process environment:

Config from env
cat > src/main.rs <<'EOF'
use std::env;
 
fn main() {
    dotenvy::dotenv().ok();
 
    let port: u16 = env::var("PORT")
        .unwrap_or_else(|_| "8080".to_string())
        .parse()
        .unwrap();
 
    println!("server berjalan di port {}", port);
}
EOF
cargo run

dotenvy::dotenv() loads .env into the environment. env::var("PORT") reads the value, with a fallback of 8080 if it is absent. Never commit .env to the repository — use .env.example as the template.

Combining Sources with the Config Crate

For complex hierarchies, the config crate combines files, environment, and defaults:

Loading configuration
cat > src/main.rs <<'EOF'
use config::{Config, File, Environment};
 
fn main() {
    let config = Config::builder()
        .set_default("host", "127.0.0.1").unwrap()
        .add_source(File::with_name("config").required(false))
        .add_source(Environment::with_prefix("APP"))
        .build()
        .unwrap();
 
    let host: String = config.get("host").unwrap();
    println!("host: {}", host);
}
EOF
cargo run

The order of sources determines priority: defaults rank lowest, the environment highest. Environment::with_prefix("APP") reads variables such as APP_HOST. Configuration is collected once at program start and shared as a struct.

Figment: Layered Configuration

Layers and Merge

figment is a configuration library used by frameworks such as Rocket and the Axum ecosystem. The principle is the same: several layers are merged with a clear priority.

Figment multi-layer
let config: Config = Figment::new()
    .merge(Toml::file("config.toml"))
    .merge(Env::prefixed("APP_"))
    .extract()?;

Env::prefixed("APP_") reads environment variables prefixed with APP_. The last layer overrides the previous ones. figment is the choice when you need direct deserialization into a struct with serde.

Schema Evolution and Data Versioning

Why Schemas Change

Data stored for years will face schema changes: new fields, renames, or type changes. Stored databases and files must remain readable by both old and new applications. Two main strategies: migration for databases and payload versioning for encoded data.

Migrations with SQLx

Migrations record schema changes in an ordered, idempotent way:

Creating a migration
cargo install sqlx-cli --features postgres
sqlx migrate add tambah_kolom_email

Fill in the generated migration file with SQL, then apply it:

SQL migration
ALTER TABLE pengguna ADD COLUMN email TEXT;

Migrations are recorded in a dedicated table, so each runs only once. sqlx migrate run ensures the schema is consistent across all environments — that is half of the schema evolution work.

Payload Versioning and Compatibility

For encoded data such as JSON in a database or events in a message queue, add a version field. New applications read old data (forward compatibility), and old applications safely reject data they do not recognize:

Versioned payload
cat > src/main.rs <<'EOF'
use serde::{Deserialize, Serialize};
 
#[derive(Serialize, Deserialize)]
struct Event {
    v: u32,
    nama: String,
}
 
fn main() {
    let lama = r#"{"v":1,"nama":"deploy"}"#;
    let event: Event = serde_json::from_str(lama).unwrap();
    println!("event v{}: {}", event.v, event.nama);
}
EOF
cargo run

The v field marks the payload version. When the schema changes, the version is bumped and the parser adjusts. Serde also supports #[serde(default)] and #[serde(alias)] for optional new fields and renames without breaking old data.

Per-Environment Config Separation

The Dev, Staging, Production Pattern

One codebase, many environments: values that are the same across environments go into the base config, while different ones are overridden per environment. The common convention in Rust:

config/default.toml
host = "0.0.0.0"
port = 8080
log_level = "info"
config/production.toml
log_level = "warn"

The config crate picks the file based on the environment: APP_ENV=production cargo run sets the environment when running the application, and the loading logic merges the default file, the environment-specific file, and environment variable overrides. This pattern keeps dev, staging, and production on the same code with different values.

Closing

Key takeaways:

  • Do not hard-code config: use environment variables and .env files.
  • dotenvy loads .env; config and figment combine many sources.
  • The layer order determines configuration priority.
  • Database migrations record schema changes idempotently.
  • Payload versioning keeps old and new data compatible.
  • Separate config per environment: default, dev, staging, production.

In the next episode 10 we will discuss basic networking and the HTTP server — building a server with axum, warp, actix-web, or hyper, request-response basics, routing, middleware, and error handling, as well as TCP and UDP connections. Your application starts talking to the outside world.

Learning Rust - Schema Evolution, Config, and Environment | Learning Rust