Learning Rust - Data Serialization, Persistence, and I/O
Episode 8 of 19

Learning Rust - Data Serialization, Persistence, and I/O

This episode discusses data management in Rust: serialization and deserialization with serde for the JSON and TOML formats, file and network stream I/O, async I/O with tokio, as well as database integration with sqlx, diesel, or sea-orm.

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

Introduction

Modern applications rarely contain static data inside the code: you read configuration, store data, call APIs, and write logs. All of it involves serialization — converting data into a format that can be sent and stored — as well as I/O with files, networks, and databases.

Episode 8 breaks down all three: serde as the de facto serialization standard, file and async I/O with tokio, and database integration with sqlx. After this episode, you will be able to build a real data layer.

Serialization with Serde

Adding Serde

serde is a serialization framework with a rich format ecosystem: JSON, TOML, YAML, and many more. Everything is driven by the Serialize and Deserialize derive macros.

Adding serde
cargo add serde --features derive
cargo add serde_json

cargo add serde --features derive enables the macros. serde_json provides the JSON format. A data structure simply needs a derive annotation:

JSON serialization
cat > src/main.rs <<'EOF'
use serde::{Deserialize, Serialize};
 
#[derive(Serialize, Deserialize)]
struct Server {
    nama: String,
    port: u16,
    aktif: bool,
}
 
fn main() {
    let server = Server {
        nama: String::from("api"),
        port: 8080,
        aktif: true,
    };
 
    let json = serde_json::to_string(&server).unwrap();
    println!("{}", json);
 
    let kembali: Server = serde_json::from_str(&json).unwrap();
    println!("{}:{}", kembali.nama, kembali.port);
}
EOF
cargo run

serde_json::to_string turns a struct into JSON, and from_str reverses the process. Because validation happens through types, mismatched data is rejected at deserialization time — not later when it is used.

TOML and YAML Formats

The same principle applies to other formats. toml::from_str parses TOML text into a struct using the same derive pattern:

Parsing TOML
cat > src/main.rs <<'EOF'
use serde::Deserialize;
 
#[derive(Deserialize)]
struct Config {
    host: String,
    port: u16,
}
 
fn main() {
    let sumber = r#"
        host = "127.0.0.1"
        port = 5432
    "#;
    let config: Config = toml::from_str(sumber).unwrap();
    println!("{}:{}", config.host, config.port);
}
EOF
cargo run

The same #[derive(Deserialize)] works for JSON, TOML, and YAML — that is the power of serde: one type definition, many formats.

File I/O

Reading and Writing Files

The standard library provides synchronous file I/O in std::fs:

Synchronous file I/O
cat > src/main.rs <<'EOF'
use std::fs;
 
fn main() {
    fs::write("catatan.txt", "belajar rust\n").unwrap();
    let isi = fs::read_to_string("catatan.txt").unwrap();
    println!("{}", isi.trim());
    fs::remove_file("catatan.txt").unwrap();
}
EOF
cargo run

fs::write, fs::read_to_string, and fs::remove_file handle the common cases. For more advanced needs, File, Read, and Write give full control. All of them return a Result, so errors can be propagated with ?.

Async I/O with Tokio

The Async Runtime

Slow I/O operations should not block a thread. tokio is the most popular async runtime: it provides an executor and non-blocking I/O.

Async file I/O
cat > src/main.rs <<'EOF'
use tokio::fs;
 
#[tokio::main]
async fn main() {
    let isi = fs::read_to_string("Cargo.toml").await.unwrap();
    println!("baris: {}", isi.lines().count());
}
EOF
cargo run

#[tokio::main] turns main into an async entry point. .await suspends execution without blocking the thread — the same thread can serve many operations concurrently. This pattern will be used fully in episodes 10 and 12.

Database Integration with SQLx

Compile-Time Checked Queries

sqlx checks SQL queries at compile time, so typos in SQL are caught before runtime:

Querying with sqlx
cat > src/main.rs <<'EOF'
use anyhow::Result;
use sqlx::postgres::PgPoolOptions;
 
#[derive(sqlx::FromRow)]
struct Pengguna {
    id: i32,
    nama: String,
}
 
#[tokio::main]
async fn main() -> Result<()> {
    let pool = PgPoolOptions::new()
        .max_connections(5)
        .connect("postgres://user:pass@localhost/db")
        .await?;
 
    let pengguna: Vec<Pengguna> = sqlx::query_as(
        "SELECT id, nama FROM pengguna WHERE aktif = true",
    )
    .fetch_all(&pool)
    .await?;
 
    println!("total: {}", pengguna.len());
    Ok(())
}
EOF
cargo run

PgPoolOptions creates a connection pool shared between tasks. query_as maps rows into the Pengguna struct. With DATABASE_URL and the postgres feature, the query! macro can validate queries against the database schema directly at compile time.

Diesel and SeaORM

Alternatives exist: diesel is a synchronous, type-safe ORM, while sea-orm is an async ORM built on top of sqlx. The choice: sqlx for full control over SQL, diesel for a mature ORM with strong migration support, sea-orm for the async ecosystem and active-record models. All support schema migrations — a theme we continue in episode 9.

Closing

Key takeaways:

  • serde with the Serialize and Deserialize derives is the serialization standard.
  • One type definition serves JSON, TOML, YAML, and other formats.
  • std::fs for synchronous I/O; tokio for non-blocking async I/O.
  • sqlx validates queries at compile time and uses a connection pool.
  • diesel for a synchronous ORM, sea-orm for an async ORM.

In the next episode 9 we will discuss schema evolution, config, and environment — application configuration with config, dotenv, or figment, data versioning and type compatibility, as well as the practice of separating config per environment for dev, staging, and production.

Learning Rust - Data Serialization, Persistence, and I/O | Learning Rust