Evolving persisted data
Introduction
This guide explains how to use thisversion, with a particular focus on
advanced workflows and the decisions involved in evolving persisted data.
For the what, the available derives, traits, types and functions, see the API reference.
The chapters below focus on decisions that arise as your data evolves. They can be read independently:
-
Adding the next schema version Evolve a working schema while preserving the meaning of existing data.
-
Adopting existing persisted data Bring unversioned data into an explicit version history.
-
Choosing how nested schemas evolve Decide whether nested schemas should have independent histories or use a fixed representation.
-
Fallible migrations Add migrations that can reject values and return errors.
-
Manually implementing conversions Map schemas to models when the derive’s field mapping is not enough.
Adding the next schema version
Imagine an application that saves a worker’s settings between runs. Its first release stores the worker’s name and always uses a 30-second timeout. Later, users need to choose their own timeout. The application must still understand the settings it saved before that option existed.
This is a reason to add a schema version. V1 continues to describe records that contain only a name. V2 stores the chosen timeout as well. A migration gives V1 records the behavior they had when they were written.
Extend the history
Keep V1’s serialized representation and meaning intact so its stored records
remain readable. Append V2 to the enum deriving
VersionFamily. The order of the variants tells the library
which migration runs next.
The application model now needs a timeout too. Move
VersionBoundary and its model attribute from V1 to V2, while
V1 keeps its Serde derives. Finally, implement the conversion from V1 to V2.
Together, the types look like this:
#![allow(unused)]
fn main() {
extern crate serde;
extern crate thisversion;
use serde::{Deserialize, Serialize};
use thisversion::{VersionBoundary, VersionFamily};
#[derive(Deserialize, Serialize, VersionFamily)]
#[serde(tag = "version", content = "data")]
enum SettingsVersions {
V1(SettingsV1),
V2(SettingsV2),
}
#[derive(Deserialize, Serialize)]
struct SettingsV1 {
name: String,
}
#[derive(Deserialize, Serialize, VersionBoundary)]
#[thisversion(model = Settings)]
struct SettingsV2 {
name: String,
timeout_secs: u32,
}
struct Settings {
name: String,
timeout_secs: u32,
}
impl From<SettingsV1> for SettingsV2 {
fn from(previous: SettingsV1) -> Self {
Self {
name: previous.name,
timeout_secs: 30,
}
}
}
}
The 30 belongs in the migration because it describes historical behavior.
For example, a stored V1 record containing {"name":"worker"} becomes a
Settings model with timeout_secs = 30. A V2 record has its own
timeout_secs value; if that field is missing, the V2 record is invalid.
When you call into_model() on a V1 family value, the library first runs the
V1-to-V2 migration, then constructs Settings. Future versions extend this
chain with another adjacent migration.
Saving a model through SettingsVersions with into_schema() now produces V2.
If older applications must read newly written data, update those readers to
recognize V2 before enabling V2 writes.
A new version can also be necessary when field types stay the same but their meaning changes. Each migration should preserve the interpretation of its source version. If the new representation cannot accept every old value, see Handling failures and model differences.
Test the persisted contract
Keep serialized fixtures from supported releases. Verify that decoding and migration produce the intended current values. Constructing an old Rust struct tests the migration, but not whether bytes written by an old release still deserialize correctly.
For this change, verify that:
- V1 settings load with
timeout_secs = 30. - V2 settings with a custom timeout are preserved.
- Newly written settings use the
V2tag.
Keep existing serialized tags stable as well. Renaming a Rust variant can change its serialized identifier unless the codec mapping preserves it explicitly.
Adopting existing persisted data
An existing application may already have persisted data without an explicit schema version. Over time, its reader may have accumulated aliases, defaults, and custom decoding rules to keep that data working.
Preserve that behavior as a legacy schema. Here we will call the struct V0.
V0 becomes the compatibility boundary for existing data: its job is to
interpret what was already in storage so it can enter the versioned history.
Preserve what the old reader knows
V0 does not need to reconstruct a history that was never recorded. Treat it as a compatibility reader for the legacy formats you can actually identify.
If two old records have the same representation but different meanings, no schema definition can determine which interpretation was intended. The application must provide some other information to distinguish them.
Select the legacy reader explicitly
A family wrapper cannot discover a version tag that was never written. Before decoding, identify legacy data using information you already have, such as its source, file location, database metadata, or an unambiguous format marker.
Once V0 has been selected, it can enter the ordinary migration path:
#![allow(unused)]
fn main() {
extern crate serde_json;
extern crate serde;
extern crate thisversion;
use thisversion::{VersionBoundary, VersionFamily};
struct Settings {
name: String,
}
#[derive(VersionBoundary)]
#[thisversion(model = Settings)]
#[derive(serde::Deserialize)]
struct V0 {
name: String,
}
#[derive(VersionFamily)]
enum Versions {
V0(V0),
}
use thisversion::traits::IntoModel;
fn read_legacy(input: &str) -> Result<Settings, serde_json::Error> {
let legacy: V0 = serde_json::from_str(input)?;
Ok(Versions::V0(legacy).into_model())
}
}
If legacy and versioned data share an entry point, determine the format first and then decode it with the appropriate reader.
Establish the new write format
Reading legacy data and writing new data are separate decisions. Once the application has a current model, retain the version identifier, one way to do this is to serialize it through the family so newly written data carries an explicit schema identity:
#![allow(unused)]
fn main() {
extern crate serde_json;
extern crate serde;
extern crate thisversion;
use thisversion::{VersionBoundary, VersionFamily};
struct Settings {
name: String,
}
#[derive(VersionBoundary)]
#[thisversion(model = Settings)]
#[derive(serde::Serialize)]
struct V1 {
name: String,
}
#[derive(serde::Serialize, VersionFamily)]
enum Versions {
V1(V1),
}
use thisversion::traits::IntoSchema;
fn write_current(settings: Settings) -> Result<String, serde_json::Error> {
let schema: Versions = settings.into_schema();
serde_json::to_string(&schema)
}
}
From this point on, new records identify their schema explicitly and can follow the normal versioned migration path.
If you serialize a bare schema instead, store its version separately—for example, in a database column. The important part is that a future reader can determine which schema should decode the record.
Decide when to rewrite legacy data
Loading a legacy record does not require immediately rewriting it. You can migrate records gradually as they are loaded and saved, or rewrite existing storage in a separate migration operation.
Whichever strategy you choose, keep the legacy reader for as long as supported inputs may still arrive in that format. That may include old records that have not yet been touched, as well as imports, restored backups, or data produced by older deployments.
Before new writers begin emitting the versioned format, make sure any readers that must continue working can understand it.
Choosing how nested schemas evolve
Imagine a document editor that originally stored a title and body as plain text. Later, content becomes a separate part of the application: it contains paragraphs and can be marked as reviewed.
Document metadata and content can now evolve independently. There are two ways to represent that relationship in a persisted schema:
-
Store the child family
Use this when the child should carry its own version and evolve independently of the parent. -
Pin a child schema
Use this when the parent’s stored format intentionally contains one particular representation of the child.
The difference determines whether the parent automatically follows new child versions or remains tied to a specific child format.
Give the child its own history
Start by giving content an independent version history. Content V1 stores paragraphs; V2 adds review state.
A version family names those stored forms, while VersionBoundary connects the
newest schema to the application’s Content model. Migrating old content makes
it unreviewed:
#![allow(unused)]
fn main() {
extern crate thisversion;
use thisversion::{VersionBoundary, VersionFamily};
use thisversion::traits::{IntoModel, IntoSchema};
#[derive(VersionFamily)]
enum ContentVersions {
V1(ContentV1),
V2(ContentV2),
}
struct ContentV1 {
paragraphs: Vec<String>,
}
#[derive(VersionBoundary)]
#[thisversion(model = Content)]
struct ContentV2 {
paragraphs: Vec<String>,
reviewed: bool,
}
struct Content {
paragraphs: Vec<String>,
reviewed: bool,
}
impl From<ContentV1> for ContentV2 {
fn from(old: ContentV1) -> Self {
Self {
paragraphs: old.paragraphs,
reviewed: false,
}
}
}
}
Content now has a history that can advance independently of any document that contains it.
Store the child family in the parent
The original document schema has a plain body. Its next version replaces that
field with ContentVersions:
#![allow(unused)]
fn main() {
extern crate thisversion;
#[derive(VersionFamily)]
enum ContentVersions {
V1(ContentV1),
V2(ContentV2),
}
struct ContentV1 {
paragraphs: Vec<String>,
}
#[derive(VersionBoundary)]
#[thisversion(model = Content)]
struct ContentV2 {
paragraphs: Vec<String>,
reviewed: bool,
}
struct Content {
paragraphs: Vec<String>,
reviewed: bool,
}
impl From<ContentV1> for ContentV2 {
fn from(old: ContentV1) -> Self {
Self {
paragraphs: old.paragraphs,
reviewed: false,
}
}
}
use thisversion::{VersionBoundary, VersionFamily};
use thisversion::traits::{IntoModel, IntoSchema};
#[derive(VersionFamily)]
enum DocumentVersions {
V0(DocumentV0),
V1(DocumentV1),
}
struct DocumentV0 {
title: String,
body: String,
}
#[derive(VersionBoundary)]
#[thisversion(model = Document)]
struct DocumentV1 {
title: String,
#[thisversion(nested)]
content: ContentVersions,
}
struct Document {
title: String,
content: Content,
}
impl From<DocumentV0> for DocumentV1 {
fn from(old: DocumentV0) -> Self {
Self {
title: old.title,
content: ContentVersions::V1(ContentV1 {
paragraphs: vec![old.body],
}),
}
}
}
}
The migration explicitly creates ContentVersions::V1. Naming the variant is
important: the old document body corresponds to Content V1, and that historical
fact should not change when Content V2 or V3 is added later.
The nested marker tells VersionBoundary to convert the field
between ContentVersions and the application’s Content model. Converting the
outer family therefore migrates both histories:
#![allow(unused)]
fn main() {
extern crate thisversion;
#[derive(VersionFamily)]
enum ContentVersions {
V1(ContentV1),
V2(ContentV2),
}
struct ContentV1 {
paragraphs: Vec<String>,
}
#[derive(VersionBoundary)]
#[thisversion(model = Content)]
struct ContentV2 {
paragraphs: Vec<String>,
reviewed: bool,
}
impl From<ContentV1> for ContentV2 {
fn from(old: ContentV1) -> Self {
Self {
paragraphs: old.paragraphs,
reviewed: false,
}
}
}
struct Content {
paragraphs: Vec<String>,
reviewed: bool,
}
#[derive(VersionFamily)]
enum DocumentVersions {
V0(DocumentV0),
V1(DocumentV1),
}
struct DocumentV0 {
title: String,
body: String,
}
#[derive(VersionBoundary)]
#[thisversion(model = Document)]
struct DocumentV1 {
title: String,
#[thisversion(nested)]
content: ContentVersions,
}
struct Document {
title: String,
content: Content,
}
impl From<DocumentV0> for DocumentV1 {
fn from(old: DocumentV0) -> Self {
Self {
title: old.title,
content: ContentVersions::V1(ContentV1 {
paragraphs: vec![old.body],
}),
}
}
}
use thisversion::traits::{IntoModel, IntoSchema};
use thisversion::{VersionBoundary, VersionFamily};
let old = DocumentVersions::V0(DocumentV0 {
title: "Draft".into(),
body: "First paragraph".into(),
});
let document = old.into_model();
assert_eq!(document.content.paragraphs, ["First paragraph"]);
assert!(!document.content.reviewed);
let stored: DocumentVersions = document.into_schema();
assert!(matches!(
stored,
DocumentVersions::V1(DocumentV1 {
content: ContentVersions::V2(_),
..
})
));
}
Document V1 may also contain Content V1 directly. Loading it still migrates the
child to the current Content model, even though the document itself is already
at its latest version.
Saving takes the opposite path and writes the latest variant of each family. If content later gains V3, Document V1 can remain the latest document schema; new writes will simply contain Content V3.
That independence also affects compatibility. A reader must understand the new child version before it can read documents that contain it, even if the parent version itself has not changed.
Pin the parent to a child schema
Sometimes the parent should not follow the child’s latest stored representation. Its persisted format may deliberately contain the fields of a particular child schema without storing a child version tag.
In that design, store the child schema directly and name its family in the
nested attribute:
#![allow(unused)]
fn main() {
extern crate thisversion;
#[derive(VersionFamily)]
enum ContentVersions {
V1(ContentV1),
V2(ContentV2),
}
struct ContentV1 {
paragraphs: Vec<String>,
}
#[derive(VersionBoundary)]
#[thisversion(model = Content)]
struct ContentV2 {
paragraphs: Vec<String>,
reviewed: bool,
}
struct Content {
paragraphs: Vec<String>,
reviewed: bool,
}
impl From<ContentV1> for ContentV2 {
fn from(old: ContentV1) -> Self {
Self {
paragraphs: old.paragraphs,
reviewed: false,
}
}
}
use thisversion::{VersionBoundary, VersionFamily};
#[derive(VersionBoundary)]
#[thisversion(model = PinnedDocument)]
struct PinnedDocumentSchema {
title: String,
#[thisversion(nested = ContentVersions)]
content: ContentV1,
}
struct PinnedDocument {
title: String,
content: Content,
}
// NOTE: this is a down migration. V2 -> v1
impl From<ContentV2> for ContentV1 {
fn from(
ContentV2 {
paragraphs,
reviewed: _, // This is lossy!
}: ContentV2,
) -> Self {
Self { paragraphs }
}
}
}
Here, nested = ContentVersions tells the derive that ContentV1 belongs to
the family that knows how to migrate it into the current Content model.
Loading still follows the child history, so Content V1 becomes a current
Content with reviewed = false.
Saving is different. The parent remains pinned to Content V1, so the current
Content V2 representation must be converted back to V1. In this example, that
conversion cannot preserve reviewed:
#![allow(unused)]
fn main() {
extern crate thisversion;
struct ContentV1 {
paragraphs: Vec<String>,
}
#[derive(VersionBoundary)]
#[thisversion(model = Content)]
struct ContentV2 {
paragraphs: Vec<String>,
reviewed: bool,
}
struct Content {
paragraphs: Vec<String>,
reviewed: bool,
}
impl From<ContentV1> for ContentV2 {
fn from(old: ContentV1) -> Self {
Self {
paragraphs: old.paragraphs,
reviewed: false,
}
}
}
#[derive(VersionFamily)]
enum ContentVersions {
V1(ContentV1),
V2(ContentV2),
}
impl From<ContentV2> for ContentV1 {
fn from(latest: ContentV2) -> Self {
Self {
paragraphs: latest.paragraphs,
}
}
}
#[derive(VersionBoundary)]
#[thisversion(model = PinnedDocument)]
struct PinnedDocumentSchema {
title: String,
#[thisversion(nested = ContentVersions)]
content: ContentV1,
}
struct PinnedDocument {
title: String,
content: Content,
}
use thisversion::traits::{IntoModel, IntoSchema};
use thisversion::{VersionBoundary, VersionFamily};
let document = PinnedDocument {
title: "Reviewed draft".into(),
content: Content {
paragraphs: vec!["First paragraph".into()],
reviewed: true,
},
};
let stored: PinnedDocumentSchema = document.into_schema();
let reloaded = stored.into_model();
assert!(!reloaded.content.reviewed);
}
The round trip deliberately loses review state because the pinned format has nowhere to store it.
If that information must survive, evolve the parent to store Content V2 or the
child family instead. IntoSchema is infallible. Here,
From<ContentV2> for ContentV1 lets the model save through the pinned parent,
deliberately losing review state. Without that reverse conversion, the derive
still supports loading, but saving to PinnedDocumentSchema does not compile.
If an export to the pinned format must reject reviewed content, provide a
separate fallible conversion that returns Result<PinnedDocumentSchema, Error>.
Choose between the two designs
The choice comes down to what the parent’s persisted contract should mean:
-
Store
ContentVersions
When content owns its version history and the parent should accept new child versions without introducing a new parent version. -
Store
ContentV1withnested = ContentVersions
When the parent intentionally promises that particular child representation, even as the child family continues to evolve elsewhere.
For either design, test old child versions nested inside the latest parent. For a pinned child, also test the full model → schema → model round trip so any information lost by the older representation is explicit.
Fallible conversions
Use TryFrom when a migration can reject old values. Keep both derives: mark
the family fallible, give the latest schema an error type, and load with
try_into_model().
Here, a retry count narrows from u32 to u8. The standard conversion supplies
TryFromIntError when the old value exceeds 255:
extern crate serde;
extern crate serde_json;
extern crate thisversion;
use std::num::TryFromIntError;
use serde::{Deserialize, Serialize};
use thisversion::{VersionBoundary, VersionFamily};
use thisversion::traits::{IntoSchema, TryIntoModel};
#[derive(Deserialize, Serialize, VersionFamily)]
#[serde(tag = "version", content = "data")]
#[thisversion(fallible)]
enum SettingsVersions {
V1(SettingsV1),
V2(SettingsV2),
}
#[derive(Deserialize, Serialize)]
struct SettingsV1 {
retries: u32,
}
#[derive(Deserialize, Serialize, VersionBoundary)]
#[thisversion(model = Settings, error = TryFromIntError)]
struct SettingsV2 {
retries: u8,
}
struct Settings {
retries: u8,
}
impl TryFrom<SettingsV1> for SettingsV2 {
type Error = TryFromIntError;
fn try_from(old: SettingsV1) -> Result<Self, Self::Error> {
Ok(Self { retries: old.retries.try_into()? })
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let input = r#"{"version":"V1","data":{"retries":3}}"#;
let stored: SettingsVersions = serde_json::from_str(input)?;
let settings = stored.try_into_model()?;
let stored: SettingsVersions = settings.into_schema();
assert!(matches!(stored, SettingsVersions::V2(_)));
Ok(())
}
Nested errors
The latest schema chooses the family’s final error type. Errors from migration
steps and nested conversions must convert into it through From.
A parent can reuse the child’s error. Add these types to the example above:
#![allow(unused)]
fn main() {
extern crate serde;
extern crate serde_json;
extern crate thisversion;
use serde::Deserialize;
struct Settings {
retries: u8,
}
#[derive(Deserialize)]
struct SettingsV1 {
retries: u32,
}
#[derive(VersionBoundary, Deserialize)]
#[thisversion(model = Settings, error = TryFromIntError)]
struct SettingsV2 {
retries: u8,
}
impl TryFrom<SettingsV1> for SettingsV2 {
type Error = TryFromIntError;
fn try_from(old: SettingsV1) -> Result<Self, Self::Error> {
Ok(Self { retries: old.retries.try_into()? })
}
}
#[derive(Deserialize, VersionFamily)]
#[serde(tag = "version", content = "data")]
#[thisversion(fallible)]
enum SettingsVersions {
V1(SettingsV1),
V2(SettingsV2),
}
use std::num::TryFromIntError;
use thisversion::{VersionBoundary, VersionFamily};
use thisversion::traits::{IntoSchema, TryIntoModel};
struct Job {
settings: Settings,
}
#[derive(VersionBoundary)]
#[thisversion(model = Job, error = TryFromIntError)]
struct JobV1 {
#[thisversion(nested)]
settings: SettingsVersions,
}
}
JobV1::try_into_model() propagates a failed settings migration. If the parent
has several error sources, choose a parent error enum and implement From for
each source. Mark a containing version family fallible too. Infallible From
migrations can remain in a fallible family; their Infallible error must also
convert into the final error type.
Validation and error handling
Latest inputs skip historical migrations. Here, V2’s u8 already prevents
out-of-range retry counts. Other invariants may need checks during decoding or
custom model construction. error = E enables error propagation;
it does not add validation to ordinary fields. Check invalid historical and
latest inputs, including nested values.
The Serde DeserializeIntoModel adapter combines decoding and migration, turning
conversion errors into Serde errors using Display. To retain domain errors
for matching, decode first, as above. To return decoding and migration errors
through one application error, add:
#![allow(unused)]
fn main() {
extern crate serde;
extern crate serde_json;
extern crate thisversion;
use serde::Deserialize;
struct Settings {
retries: u8,
}
#[derive(Deserialize)]
struct SettingsV1 {
retries: u32,
}
#[derive(VersionBoundary, Deserialize)]
#[thisversion(model = Settings, error = TryFromIntError)]
struct SettingsV2 {
retries: u8,
}
impl TryFrom<SettingsV1> for SettingsV2 {
type Error = TryFromIntError;
fn try_from(old: SettingsV1) -> Result<Self, Self::Error> {
Ok(Self { retries: old.retries.try_into()? })
}
}
#[derive(VersionFamily, Deserialize)]
#[serde(tag = "version", content = "data")]
#[thisversion(fallible)]
enum SettingsVersions {
V1(SettingsV1),
V2(SettingsV2),
}
use std::num::TryFromIntError;
use thisversion::{VersionBoundary, VersionFamily};
use thisversion::traits::{IntoSchema, TryIntoModel};
#[derive(Debug)]
enum LoadError {
Decode(serde_json::Error),
Migration(TryFromIntError),
}
fn load(input: &str) -> Result<Settings, LoadError> {
let stored: SettingsVersions =
serde_json::from_str(input).map_err(LoadError::Decode)?;
stored.try_into_model().map_err(LoadError::Migration)
}
}
Manual conversions
Use VersionBoundary when matching schema and model fields can move directly or
convert through nested. For custom mapping or model validation, implement
IntoModel or TryIntoModel on the latest schema yourself. VersionFamily
still connects the history. Ordinary From and TryFrom migrations remain
part of the derive workflow described earlier.
Custom model mapping
Here the schema’s display_name becomes the model’s name, which must be
nonempty when loaded. The manual conversion replaces VersionBoundary:
#![allow(unused)]
fn main() {
extern crate thisversion;
use thisversion::VersionFamily;
use thisversion::traits::{IntoModel, IntoSchema, TryIntoModel};
#[derive(VersionFamily)]
#[thisversion(fallible)]
enum ContactVersions {
V1(ContactV1),
}
struct ContactV1 {
display_name: String,
}
struct Contact {
name: String,
}
#[derive(Debug)]
struct EmptyName;
impl TryIntoModel for ContactV1 {
type Model = Contact;
type Error = EmptyName;
fn try_into_model(self) -> Result<Contact, EmptyName> {
if self.display_name.trim().is_empty() {
return Err(EmptyName);
}
Ok(Contact { name: self.display_name })
}
}
impl IntoSchema<ContactVersions> for Contact {
fn into_schema(self) -> ContactVersions {
ContactVersions::V1(ContactV1 { display_name: self.name })
}
}
}
The validation runs for every version that reaches this latest schema,
including V1 itself. Saving is implemented separately because replacing the
derive also removes its generated output conversion. For an infallible mapping,
implement IntoModel instead and use an infallible family. A manual schema
needs its own TryIntoModel implementation if used directly with the Serde
adapter, which requires that trait.
Several sources, one model
Another family can feed the same Contact. Add these definitions:
#![allow(unused)]
fn main() {
extern crate thisversion;
struct Contact {
name: String,
}
use thisversion::traits::IntoModel;
use thisversion::VersionFamily;
#[derive(VersionFamily)]
enum DirectoryVersions {
V1(DirectoryV1),
}
struct DirectoryV1 {
first_name: String,
last_name: String,
}
impl IntoModel for DirectoryV1 {
type Model = Contact;
fn into_model(self) -> Contact {
Contact { name: format!("{} {}", self.first_name, self.last_name) }
}
}
}
Assuming directory names are already valid, each family now follows its own
history into Contact. The directory mapping discards the original name parts,
so it deliberately has no reverse conversion. If export must preserve them,
retain them in the model or elsewhere in the application. Supporting an input
format does not require supporting it as an output format.