Adventures in Rust borrowing deserialisation

25 January 2026

serde is one of those Rust libraries where there always seems to be something new to learn. Despite using it for years, it was only last week that I first had to grapple seriously with deserialising to structs that borrow. This gave me four surprises, none of which is particularly earth-shattering but I thought I'd share some notes.

The idea is this: imagine you have a JSON string and you want to parse it into some strongly-typed struct to read data out of it. Maybe that struct contains some_field: String. If you do this, the deserialisation process will create a new heap allocation for that String and copy the relevant chunk of UTF-8 over from the original JSON. Sometimes you need an owned/'static struct and this is exactly what you want. If, however, you're just reading some of the fields and then throwing it away then this was wasteful. Why did we have to allocate and copy that String? Why not have some_field: &str that's a slice into the original JSON already sitting there in memory?

In this situation I was working with the jacquard ecosystem of crates, which arguably takes this borrowing concept to extremes. It uses schemas to generate Rust structs that I can deserialise into and all of them borrow. That is, those structs have a lifetime attached like Document<'a>.

One thing that's important to know is that there are actually two separate deserialisation traits. Normally you #[derive(serde::Deserialize)] but behind the scenes, you may get another trait called DeserializeOwned. This happens when the struct doesn't contain any borrowed members, which means you can deserialise from an owned value. This is more subtle than it sounds.

Surprise 1: if the struct borrows, you can't deserialise an owned value

Imagine we're trying to deserialise JSON into a struct like this

struct Foo<'a> {
    some_field: &'a str,
}

Logically it wouldn't seem to matter whether the source JSON is a &str or a String. If it's a string slice &str we're just taking more borrows into already-borrowed memory. If it's an owned String then that's also fine; our some_field can borrow from it.

The trouble is the API where you provide the source JSON for deserialisation. Imagine it looked like this:

let json: String = "{\"some_field\":\"cat\"}".to_owned();
let f: Foo<'_> = deserialiser.from_string(json);

This cannot possibly compile. We're moving our owned String into the from_string() call. It can't return a Foo<'_> to us because the memory it's referring to is gone. We need to keep our string on the outside and use a &str.

let json: String = "{\"some_field\":\"cat\"}".to_owned();
let f: Foo<'_> = deserialiser.from_str(&json);

From our application's perspective we've deserialised a borrowed struct from owned data. From the perspective of the deserialiser API, borrowed output must imply borrowed input.

This is relatively easy to fix for a string but my situation was a bit more tricky. I needed to parse JSON in two stages:

{
    "outer_field": "foo",
    "another_one": "bar",
    "inner_object": {},
}

inner_object could contain anything at all. I wanted to parse the outer fields and let the user of my API decide how to deal with the inner_object JSON. serde_json has a solution for this—I can declare inner_object as serde_json::Value and it will parse it into a general-purpose JSON dictionary which you can query for fields dynamically.

The trouble comes later when I want to parse this into a borrowing struct like Document<'_>. I tried to do this:

let doc: Document<'_> = serde_json::from_value(inner_object); // moves inner_object!

This is exactly the same situation as before. Because from_value takes ownership of the Value it isn't possible for the output to borrow from it. The "obvious" workaround is... not great.

let inner_json = inner_object.to_string(); // ugh
let doc: Document<'_> = serde_json::from_str(&inner_json);

At this point you may wonder, as I did, why there isn't a serde_json::from_value_ref or something like that. I haven't bothered to research the true reason but when you think about it, it doesn't really make sense to first deserialise to some intermediate type and then to your target type. Surely you want to just go from the original JSON string to your target object and cut out the middleman? Which brings us to...

Surprise 2: serde_json lets you defer parsing subobjects with RawValue

serde_json has an optional feature raw_value which lets you represent subobjects as inner_object: &RawValue. Unlike Value, this does not parse the content into a dictionary. Instead it contains a &str slice into the original JSON, spanning just the bytes which represent that field's value.

#[derive(serde::Deserialize)]
struct MyStruct<'a> {
    outer_field: String,
    another_one: String,
    inner_object: &'a RawValue,
}

Now I can skip the middle step and the lifetime of doc is tied to the original JSON that gave me MyStruct:

let doc: Document<'_> = serde_json::from_str(inner_object.get());

In reality this isn't exactly what I did. Transporting the RawValue around together with the original source buffer is quite tricky and requires a self-referential struct as provided by a library like self_cell. Instead, immediately after parsing the RawValue I used it to calculate the offset between the two &strs. Now I only have to handle owned values: the original JSON, and the offset and length of inner_object. I can reconstruct the &str slice later and give it to the user to process it however they like—no extra copies or allocations required.

Surprise 3: rustdoc can be misleading about blanket implementations

If I look at the rustdoc page for my generated Document<'_> struct then it shows:

impl<T> DeserializeOwned for T
where
    T: for<'de> Deserialize<'de>,

What's this? You're telling me that it does implement DeserializeOwned? Well, no. It can never apply because of the trait bound T. The formal definition of DeserializeOwned is that you can deserialize from any lifetime <'de>. If our struct has fields that borrow then it can't do that, so this trait doesn't apply. Rustdoc knows about the blanket impl but it can't tell that the trait bound doesn't apply so it gets listed on the page anyway. Alas.

Surprise 4: compilation errors around DeserializeOwned are tricky to understand

The somewhat roundabout way DeserializeOwned is defined means that the compiler doesn't give you a straightforward error when you're missing it. See this Rust playground for example. This function needs a type that implements DeserializeOwned:

fn needs_owned<T: serde::de::DeserializeOwned>() { /* ... */ }

If I call this with a T that implements Deserialize, except it borrows, then I get this error:

error: implementation of `Deserialize` is not general enough
  --> src/main.rs:13:5
   |
13 |     needs_owned::();
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ implementation of `Deserialize` is not general enough
   |
   = note: `StructBorrows<'_>` must implement `Deserialize<'0>`, for any lifetime `'0`...
   = note: ...but `StructBorrows<'_>` actually implements `Deserialize<'1>`, for some specific lifetime `'1`

You might expect it to say something straightforward like "that's not DeserializeOwned". Unfortunately it's getting hung up on explaining why this type doesn't meet the requirements for DeserializeOwned, without bothering to explain that's what it's doing. If you get one of these errors one day, now you'll know.

If I had chosen to write this code in Go I never would have had to think about any of this and I expect it would have been fine. C'est la vie.


Serious Computer Business Blog by Thomas Karpiniec
Posts RSS, Atom