ZetCode

Introduction to Rust

last modified October 25, 2026

This is an introduction to the Rust programming language. Rust started as a personal project of Graydon Hoare, was sponsored by Mozilla, and was announced in 2010. The first stable release, Rust 1.0, appeared in May 2015. Today the language is developed in the open by the Rust Project, an international community of volunteers and companies; the non-profit Rust Foundation (founded in 2021) supports the project. The website of the language is https://www.rust-lang.org/.

An online development environment is available at https://play.rust-lang.org/.

Rust Programming Language

Rust is a compiled, statically typed systems programming language focused on safety, speed, and concurrency. It allows us to write reliable and efficient software without the overhead of a garbage collector. Its rich type system and ownership model guarantee memory safety and thread safety, eliminating many classes of bugs, such as null pointer dereferences, use-after-free errors, and data races, at compile time.

Rust is especially well suited for performance-critical applications, such as operating systems, game engines, web browsers, and embedded devices. Thanks to its powerful abstractions and zero-cost principles, Rust also excels in WebAssembly, networking, and command-line tools. Rust code is used in projects such as Firefox, the Linux kernel, and Android.

Key features of Rust include:

A new stable version of the compiler is released every six weeks. Backward incompatible changes are collected in editions, which are published about every three years (2015, 2018, 2021, and 2024). Crates of different editions can be combined in one program. The examples in this tutorial were tested with Rust 1.97 and the 2024 edition.

Rust installation

The recommended way to install Rust is through rustup, the Rust toolchain installer. For current instructions, see the official installation page https://www.rust-lang.org/tools/install.

On Linux, macOS, and other Unix-like systems, we can install Rust with the following command. On Windows, we download and run the rustup-init.exe installer.

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

The installer puts the compiler rustc, the build tool cargo, the standard library, and the tools rustfmt and clippy into the ~/.cargo/bin directory. The rustup update command installs newer versions later. After the installation, we verify the installed versions with the --version option.

$ rustc --version
rustc 1.97.1 (8bab26f4f 2026-07-14)
$ cargo --version
cargo 1.97.1 (c980f4866 2026-06-30)

For VS Code, install the rust-analyzer extension, which provides language support, code completion, inline hints, and debugging capabilities.

Rust simple example

The following is a simple example in Rust. We save it in a file named main.rs.

main.rs
fn main() {
    println!("First program in Rust");
}

The program prints a message to the console. Rust source files use the .rs extension. The main function is the entry point of every Rust program. The function is defined with the fn keyword; it takes no parameters and returns no value.

The body of the function is enclosed in curly brackets. The println! macro prints a string to the standard output, followed by a newline. The exclamation mark indicates that it is a macro, not a regular function. Statements are terminated with a semicolon. By convention, Rust code is indented with four spaces.

We compile the program with the rustc compiler and run the resulting executable. On Windows, the executable is main.exe.

$ rustc main.rs
$ ./main
First program in Rust

Creating a project with Cargo

Real programs are built with Cargo. The cargo new command creates a new project in a directory of the given name. The directory contains a Cargo.toml file, which describes the package and lists its dependencies, and a src directory with a main.rs file that already contains a "Hello, world!" program. Cargo also initializes a Git repository (unless the directory is already inside one), which is why there is a .git directory and a .gitignore file. The compiled files are later placed in the target directory, which is ignored by Git.

In the example, we create a project and list its files. The file Cargo.toml contains the name of the package, its version, and the edition of the language.

$ cargo new hello
    Creating binary (application) `hello` package
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
$ ls -A hello
Cargo.toml
.git
.gitignore
src
$ ls hello/src
main.rs
$ cat hello/Cargo.toml
[package]
name = "hello"
version = "0.1.0"
edition = "2024"

[dependencies]
$ cat hello/src/main.rs
fn main() {
    println!("Hello, world!");
}

Building and running with Cargo

The cargo run command compiles the project and runs the program. The compiler messages go to the standard error output, and the output of the program goes to the standard output.

We run the project twice. The second time Cargo sees that nothing has changed, so it skips the compilation. The -q option, which we use in all other examples, suppresses the messages of Cargo and shows only the output of the program.

$ cd hello
$ cargo run
   Compiling hello v0.1.0 (/home/user/projects/hello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.11s
     Running `target/debug/hello`
Hello, world!
$ cargo run
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/hello`
Hello, world!

Checking and release builds

The cargo check command only checks that the code compiles, which is faster than a full build. The cargo build command compiles without running; it creates an unoptimized debug build in target/debug. The --release option turns on optimizations and places the result into target/release.

In the example, we check and build a release version, and list the contents of the target directory. The release build takes longer to compile but produces faster programs; the debug build is meant for development. The Cargo tutorial covers dependencies, profiles, and workspaces.

$ cd hello
$ cargo check
    Checking hello v0.1.0 (/home/user/projects/hello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.06s
$ cargo build --release
   Compiling hello v0.1.0 (/home/user/projects/hello)
    Finished `release` profile [optimized] target(s) in 0.06s
$ ls target
CACHEDIR.TAG
debug
release

Rust variables

Variables store values, and by default they are immutable.

main.rs
fn main() {
    let name = "John Doe";
    let age = 34;

    println!("{name} is {age} years old");

    let mut visits = 0;
    visits += 1;
    visits += 1;
    println!("{name} has visited us {visits} times");

    let age = age + 1;
    println!("{name} will be {age} next year");
}

In the example, we declare a string and an integer variable.

let name = "John Doe";
let age = 34;

The let keyword creates a variable, and Rust infers the types automatically: &str for the string literal and i32 for the integer. Variable bindings are immutable by default.

let mut visits = 0;
visits += 1;

To make a variable mutable, we use let mut. The compiler refuses to compile a program that changes an immutable variable.

let age = age + 1;

Declaring a variable with an existing name is called shadowing. The new variable hides the old one. See the variable and mutability tutorials for details.

println!("{name} is {age} years old");

Rust uses placeholders like {} inside the format string to insert values. A variable name inside the braces is inserted directly; this works for plain variable names. Expressions are passed as separate arguments, as in println!("{}", a + b).

$ cargo run -q
John Doe is 34 years old
John Doe has visited us 2 times
John Doe will be 35 next year

Rust data types

Rust is statically typed; the type of every value is known at compile time. The compiler can usually infer it, but we can also write it after a colon. The built-in scalar types are:

Type Description
i8, i16, i32, i64, i128, isizeSigned integers
u8, u16, u32, u64, u128, usizeUnsigned integers
f32, f64Floating-point numbers
booltrue or false
charA Unicode scalar value, written in single quotes

The number in the name of an integer type is its size in bits. The isize and usize types have the size of a pointer (64 bits on a 64-bit system); usize is used for indexes and lengths. The default types are i32 and f64.

main.rs
fn main() {
    let a: i32 = -42;
    let b: u8 = 255;
    let c: f64 = 2.5;
    let d: bool = true;
    let e: char = 'Z';
    let s: &str = "Rust";
    let t: (i32, &str) = (1, "one");
    let arr: [i32; 3] = [1, 2, 3];

    println!("i32: {a}, {} bytes", size_of_val(&a));
    println!("u8: {b}, {} byte", size_of_val(&b));
    println!("f64: {c}, {} bytes", size_of_val(&c));
    println!("bool: {d}, {} byte", size_of_val(&d));
    println!("char: {e}, {} bytes", size_of_val(&e));
    println!("&str: {s}, {} bytes", size_of::<&str>());
    println!("tuple: {t:?}, {} bytes", size_of_val(&t));
    println!("array: {arr:?}, {} bytes", size_of_val(&arr));
}

In the example, we declare values of the basic types and print them with their sizes.

let t: (i32, &str) = (1, "one");
let arr: [i32; 3] = [1, 2, 3];

A tuple groups values of different types, and an array holds a fixed number of values of the same type; its length is part of the type. Collections that can grow, such as Vec and String, are covered in the vectors and strings tutorials.

println!("i32: {a}, {} bytes", size_of_val(&a));

The size_of_val function returns the size of a value in bytes. A char takes four bytes, because it can hold any Unicode scalar value. A &str is a pointer and a length, so it takes 16 bytes on a 64-bit system, whatever the length of the text. The tuple takes 24 bytes: 4 for the integer, 16 for the string slice, and 4 bytes of padding. The sizes of usize, isize, and references depend on the platform.

$ cargo run -q
i32: -42, 4 bytes
u8: 255, 1 byte
f64: 2.5, 8 bytes
bool: true, 1 byte
char: Z, 4 bytes
&str: Rust, 16 bytes
tuple: (1, "one"), 24 bytes
array: [1, 2, 3], 12 bytes

Rust functions and control flow

Functions are defined with the fn keyword. The types of the parameters and the return value are always written. The last expression of a function, without a semicolon, is its return value.

main.rs
fn square(n: i32) -> i32 {
    n * n
}

fn describe(n: i32) -> &'static str {
    match n % 3 {
        0 => "divisible by three",
        1 => "remainder one",
        _ => "remainder two",
    }
}

fn main() {
    for i in 1..=5 {
        if i % 2 == 0 {
            println!("{i} is even, square {}, {}", square(i), describe(i));
        } else {
            println!("{i} is odd, square {}, {}", square(i), describe(i));
        }
    }
}

In the example, we define two functions and call them in a loop.

fn square(n: i32) -> i32 {
    n * n
}

The function takes an i32 and returns an i32. The expression n * n has no semicolon, so its value is returned.

match n % 3 {
    0 => "divisible by three",
    1 => "remainder one",
    _ => "remainder two",
}

The match expression compares a value with patterns. The underscore matches everything else, and the compiler checks that all cases are covered. The result of the expression is the return value of the function.

for i in 1..=5 {
    if i % 2 == 0 {
        ...
    } else {
        ...
    }
}

The range 1..=5 includes both ends; 1..5 would stop at 4. The conditions of if need no parentheses but must be of type bool. See the functions, control flow, and match expression tutorials for more.

$ cargo run -q
1 is odd, square 1, remainder one
2 is even, square 4, remainder two
3 is odd, square 9, divisible by three
4 is even, square 16, remainder one
5 is odd, square 25, remainder two

Rust compiler as a teacher

The ownership rules are what makes Rust unique. Every value has exactly one owner, and when the owner goes out of scope, the value is dropped. Assigning a value that owns heap memory to another variable moves it.

main.rs
fn main() {
    let first = String::from("Rust");
    let second = first;

    println!("{first} {second}");
}

The program tries to use a String after it was moved.

let second = first;
println!("{first} {second}");

The String was moved to second, and first is no longer valid. In C, such code would compile and may lead to a double free or to the use of freed memory; in Rust, the program is rejected with error E0382. Rust error messages show the relevant lines, explain what happened, and often suggest a fix. Here we can clone the string or borrow it. The ownership and borrowing tutorial explains these rules in detail.

$ cargo run -q
error[E0382]: borrow of moved value: `first`
 --> src/main.rs:5:16
  |
2 |     let first = String::from("Rust");
  |         ----- move occurs because `first` has type `String`, which does not implement the `Copy` trait
3 |     let second = first;
  |                  ----- value moved here
4 |
5 |     println!("{first} {second}");
  |                ^^^^^ value borrowed here after move
  |
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let second = first.clone();
  |                       ++++++++

For more information about this error, try `rustc --explain E0382`.

Rust user input

The std::io module provides input/output operations for the console.

main.rs
use std::io::{self, Write};

fn main() {
    print!("Enter your name: ");
    io::stdout().flush().expect("cannot flush stdout");

    let mut name = String::new();
    io::stdin()
        .read_line(&mut name)
        .expect("failed to read a line");

    let name = name.trim();
    println!("Hello {name}");
}

The example prompts the user for a name and prints a greeting.

use std::io::{self, Write};

We import the std::io module and the Write trait. The self keyword is used to import the module itself.

print!("Enter your name: ");
io::stdout().flush().expect("cannot flush stdout");

We use the print! macro to write a prompt without a newline. Because the standard output is line-buffered, we flush it manually with flush. Otherwise the prompt might not be visible before the program waits for input. The flush method returns a Result; the expect method ends the program with the given message if an error occurs.

let mut name = String::new();
io::stdin()
    .read_line(&mut name)
    .expect("failed to read a line");

We create a mutable, empty String to hold the input. The read_line method appends the line typed by the user, including the trailing newline, to the string. It takes a mutable reference, written &mut name, so the method can modify our string. It returns a Result with the number of bytes read.

let name = name.trim();

We remove the trailing newline by calling trim. This returns a string slice (&str) without the surrounding whitespace. The new name shadows the old one. The output below was recorded in a terminal; the user typed "Peter".

$ cargo run -q
Enter your name: Peter
Hello Peter

Rust parsing user input

Input is always text. To get a number, we parse the string. Parsing can fail, so parse returns a Result, which we handle with match.

main.rs
use std::io::{self, Write};

fn main() {
    print!("Enter your age: ");
    io::stdout().flush().expect("cannot flush stdout");

    let mut input = String::new();
    io::stdin()
        .read_line(&mut input)
        .expect("failed to read a line");

    match input.trim().parse::<u32>() {
        Ok(age) => println!("Next year you will be {}", age + 1),
        Err(e) => println!("Invalid age: {e}"),
    }
}

The program reads an age and prints the age of the next year.

match input.trim().parse::<u32>() {
    Ok(age) => println!("Next year you will be {}", age + 1),
    Err(e) => println!("Invalid age: {e}"),
}

The type in the turbofish ::<u32> tells parse what to produce. The result is either Ok with the number or Err with a description of the problem. The program does not crash on wrong input; it reports the error. We run it twice: first with a valid number and then with text.

$ cargo run -q
Enter your age: 34
Next year you will be 35
$ cargo run -q
Enter your age: abc
Invalid age: invalid digit found in string

Where to go next

The following tutorials continue from here:

Source

The Rust Programming Language (The Book)

Rust by Example

The Rust Edition Guide

This was an introduction to the Rust programming language.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Rust tutorials.