Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Predicting Ownership Transfers and Drop Points in Rust

Good to see you again. In the previous lesson, you set up media-player as one Cargo package with a reusable library crate and a thin binary crate. That boundary will matter soon: the library will own player state, network clients, buffers, and eventually futures and tasks. Before we introduce asynchronous code, you need to be able to answer a more fundamental question about any value: who owns it now, and exactly when will it be cleaned up?

This lesson develops a practical method for predicting ownership transfers and drop points. We will use String as a visible example, but the same reasoning will later apply to segment buffers, HTTP responses, task handles, channels, and native decoder resources.


Ownership as a resource ledger

Rust’s ownership model can be summarized by three rules:

  1. Every value has an owner.
  2. A value has one owner at a time.
  3. When its owner goes out of scope, Rust drops the value.

“Dropped” means Rust runs the value’s cleanup logic. For a String, that ultimately releases its heap allocation. For a file, socket, lock, or later an FFmpeg wrapper, cleanup may release an external resource. The important point is broader than memory: ownership determines responsibility for cleanup.

What is Ownership? - The Rust Programming Language

Read the opening of the Rust Book’s ownership chapter to establish the core rules, scopes, and automatic cleanup model. This is the vocabulary used throughout the course.

In the opening discussion before “Ownership Rules,” read the motivation for ownership. Then read the “Ownership Rules,” “Variable Scope,” and “Memory and Allocation” sections. Focus on the fact that a variable becomes valid at its declaration, remains valid in its enclosing scope, and causes its owned value to be dropped when that ownership ends.

A source-level scope is commonly delimited by braces:

fn main() {
    let playlist = String::from("intro.m3u8");

    {
        let segment = String::from("segment-001.m4s");
        println!("{segment}");
    } // `segment` is dropped here

    println!("{playlist}");
} // `playlist` is dropped here

The inner segment exists only inside the nested block. At the inner closing brace, it is dropped. playlist remains valid because it belongs to the outer scope, and it is dropped only at the end of main.

When reading code, do not equate “the variable name is no longer useful” with “the underlying value has been dropped.” A move makes the old binding unusable, but the value continues to live under its new owner.

A reliable starting procedure is:

  1. Mark where each owned value is created.
  2. Identify the current owner after each assignment, function call, or return.
  3. Mark each scope end, reassignment, and explicit std::mem::drop call.
  4. At each possible drop point, ask whether that binding still owns a value.

Assignment: move, copy, or clone

Consider this assignment:

let manifest_url = String::from("https://example.test/stream.mpd");
let request_url = manifest_url;

Because String does not implement Copy, this is a move. Ownership of the String transfers from manifest_url to request_url.

println!("{request_url}"); // valid
// println!("{manifest_url}"); // error: value was moved

At the end of the scope, only request_url drops the string. manifest_url is no longer an owner, so it has no value left to clean up.

A conceptual representation of moving a `String`: the new owner, `fruit2`, holds the string metadata that points to the heap allocation, while the original binding, `fruit1`, is invalidated. The allocation is released once by the valid owner when it is dropped.

For a String, the value stored directly in the local variable contains metadata including a pointer, length, and capacity; the text bytes are elsewhere in memory. Rust does not implicitly duplicate those bytes during ordinary assignment. Instead, it invalidates the old owner, preventing two owners from trying to release the same allocation.

It is useful to treat the stack-and-heap diagram as an intuition, not as the definition of the rule. The language-level test is this:

  • A type implementing Copy is copied on assignment and function calls.
  • A type not implementing Copy is moved unless you explicitly clone it or borrow it.

What is Ownership? - The Rust Programming Language

Continue in the Rust Book for the central distinction between a move, an explicit clone, and a cheap Copy operation.

In “Variables and Data Interacting with Move,” read from the assignment explanation through the end of “Scope and Assignment.” Pay particular attention to why s1 becomes invalid after let s2 = s1;, and why assigning a new value to an existing binding drops its old value immediately. Then read “Variables and Data Interacting with Clone” and “Stack-Only Data: Copy,” focusing on the distinction between an explicit clone and types that implement Copy.

Copy: the original remains usable

Simple types such as integers, booleans, and characters implement Copy:

let retries: u32 = 3;
let initial_retries = retries;

println!("{retries}");
println!("{initial_retries}");

Both bindings are valid. The assignment copied the numeric value.

Many composite types can also be Copy if all their fields are Copy:

let window = (0_u64, 5_u64);
let saved_window = window;

println!("{window:?}");
println!("{saved_window:?}");

By contrast, a tuple containing a String is not Copy:

let segment = (42_u64, String::from("audio-42.m4s"));
let queued_segment = segment;

// `segment` cannot be used here.
println!("{queued_segment:?}");

The tuple moves because one of its fields is an owned String.

clone: make an intentional second value

If two independently owned strings are needed, explicitly request a clone:

let original_url = String::from("https://cdn.example.test/video.mpd");
let log_url = original_url.clone();

println!("requesting {original_url}");
println!("logging {log_url}");

For String, clone() allocates new storage and copies the text bytes. At scope end, both variables own separate values and each is dropped.

Do not adopt the simplistic rule “heap values move; stack values copy.” It happens to predict common beginner examples, but it is not the real rule. Copy is a trait-based property of the type. Rust deliberately makes cloning visible in source code because cloning may perform meaningful work and allocate memory.


Moves through function parameters and return values

Passing a value to a function follows the same rule as assigning it to a new variable.

fn consume_url(url: String) {
    println!("fetching {url}");
} // `url` is dropped here

fn main() {
    let url = String::from("https://example.test/stream.mpd");

    consume_url(url);

    // `url` is invalid: ownership moved into `consume_url`.
}

The value starts in main, moves into the parameter url, and is dropped at the end of consume_url. There is no hidden second owner.

With a Copy type, the caller keeps its own value:

fn report_attempt(attempt: u32) {
    println!("attempt {attempt}");
}

fn main() {
    let attempt = 1;

    report_attempt(attempt);

    println!("still on attempt {attempt}");
}

Here attempt is copied into the parameter. The caller’s attempt remains valid.

A function can also return ownership to its caller:

fn normalize_url(url: String) -> String {
    url.trim_end_matches('/').to_owned()
}

fn main() {
    let raw_url = String::from("https://example.test/stream/");
    let normalized_url = normalize_url(raw_url);

    // `raw_url` was moved into `normalize_url`.
    println!("{normalized_url}");
}

This particular function receives ownership of raw_url. It constructs and returns a new String, which moves into normalized_url. On return, the parameter url is dropped normally; the newly returned string has a different owner in main.

The ownership path is often easiest to annotate in prose:

  • raw_url owns the original string.
  • Calling normalize_url(raw_url) moves that string into the function parameter.
  • The parameter is dropped as the function finishes.
  • The returned String moves into normalized_url.
  • normalized_url is dropped when main ends.

Understanding Ownership in Rust

Watch “Understanding Ownership in Rust” by Let’s Get Rusty for a compact visual explanation of assignment moves, cloning, Copy types, and ownership transfer through calls and returns.

Watch moves and copies to reinforce why String assignment invalidates the original binding while integer assignment does not. Then watch function transfers, focusing on the fact that supplying an owned argument creates a new owner in the callee and that a return value can transfer ownership back to the caller.

This predicts a large class of errors you will encounter later with async tasks. For example, when a future or task takes an owned Vec<u8>, the buffer has moved into that future or task. If the caller needs its own independent buffer afterward, it must arrange that deliberately, often by borrowing, moving back a value, sharing through an appropriate ownership type, or cloning only when that is genuinely required. References and borrowing are the subject of the next lesson.


Drop points: scope end, reassignment, and explicit cleanup

The usual drop point is the end of a scope. Local variables are dropped in reverse order of their creation.

You can observe this by giving a type a Drop implementation:

struct Resource(&'static str);

impl Drop for Resource {
    fn drop(&mut self) {
        println!("releasing {}", self.0);
    }
}

fn main() {
    let network = Resource("network client");
    let decoder = Resource("decoder");

    println!("player initialized");
} // first `decoder`, then `network`

The output order is:

player initialized
releasing decoder
releasing network client

This reverse order is practical. If a later local value depends on an earlier one, it is cleaned up first. In real player code, this can matter when one type manages a resource used by another.

Running Code on Cleanup with the Drop Trait

Read this Rust Book chapter to see how custom cleanup works, why locals are dropped in reverse creation order, and how to request early cleanup safely.

Read the opening explanation of the Drop trait and the CustomSmartPointer example. Then read the output discussion beginning the observed order. Continue through the explanation of std::mem::drop. Focus on the difference between implementing Drop::drop and calling the standard-library drop function to end ownership early.

Reassignment drops the old value immediately

A binding can receive a new value. If it already owns an old non-Copy value, Rust drops that old value before storing the replacement:

let mut selected_audio = String::from("audio-low");
selected_audio = String::from("audio-high");

// The `"audio-low"` String was dropped at the reassignment.
// `"audio-high"` is dropped at the end of the scope.

This is a distinct drop point. The first string does not wait for the final closing brace, because no owner remains for it after reassignment.

std::mem::drop ends ownership now

Occasionally a resource must be released before its lexical scope ends:

let response_body = String::from("segment bytes");

// Process the body here.

std::mem::drop(response_body);

// `response_body` cannot be used after this point.
println!("the resource is released before later work");

Calling std::mem::drop(response_body) moves the value into the standard-library function, and the value is then dropped immediately. You do not call response_body.drop() yourself; Rust disallows direct destructor calls because automatic cleanup would otherwise risk cleaning up the same value twice.

A particularly important future use case involves locks. Holding a lock or resource guard longer than needed can interfere with concurrent work; ending it explicitly can make the intended lifetime clear. In the async portion of the course, this idea becomes essential because a guard should generally not remain held across an .await.


A full ownership trace

Here is a compact example in the vocabulary of the player:

fn prepare_segment(segment: String) -> String {
    println!("preparing {segment}");
    segment
}

fn main() {
    let segment_id = String::from("video-012.m4s");
    let retry_count: u32 = 2;

    let queued_id = segment_id;
    let ready_id = prepare_segment(queued_id);
    let copied_retry_count = retry_count;

    println!("{ready_id}, retry {copied_retry_count}");
}

Work through it line by line:

Code event Ownership result Drop implication
segment_id is created segment_id owns one String It would drop at main’s end unless moved first.
queued_id = segment_id The String moves to queued_id segment_id is invalid and will not drop the string.
prepare_segment(queued_id) The String moves into parameter segment queued_id becomes invalid.
segment is returned The String moves into ready_id The function does not drop the returned value.
copied_retry_count = retry_count The u32 is copied Both integer bindings remain valid.
End of main ready_id still owns the String It is dropped here, once.

The essential habit is to track the value, not merely the spelling of variable names. The string has one continuous lifetime and one current owner at a time:

segment_id, then queued_id, then parameter segment, then ready_id

The names change, but each move transfers responsibility rather than duplicating the resource.

One precision worth retaining for later custom resource wrappers: local variables in a scope are dropped in reverse declaration order. For a struct, Rust first runs that struct’s Drop implementation, if it has one, and then drops its fields. This is relevant when you eventually build a Rust type around a native FFmpeg handle: its cleanup policy belongs in Drop, while ownership determines when that policy runs.


Key takeaways

Ownership lets you predict cleanup without a garbage collector:

  • Assignment, function arguments, and return values can transfer ownership.
  • Non-Copy values such as String move by default; the old binding becomes invalid.
  • Copy types are duplicated, leaving the original binding valid.
  • clone() creates an explicit second value; for String, that includes copying its contents.
  • A value is normally dropped when its current owner leaves scope.
  • Reassignment drops the replaced owned value immediately.
  • std::mem::drop(value) requests deliberate early cleanup and consumes the value.
  • Locals at a scope boundary are dropped in reverse order of their creation.

Next, we will refactor code that would otherwise transfer ownership unnecessarily. References and borrowing let a function inspect or modify player data while its original owner remains responsible for cleanup.

Can't find a good explanation? Sign up and we'll make it for you

Sign up