Skip to content

What Does C++ to Rust Migration Actually Cost?

Problem

Last year, my team proposed rewriting our 800,000-line C++ codebase to Rust. Management loved the idea: memory safety, modern tooling, enthusiastic hiring. We estimated 18 months. We’re now 24 months in, 40% complete, and the project has been cancelled.

What went wrong? We underestimated the cost by roughly 300%.

Here’s what nobody tells you about C++ to Rust migration:

Migration Cost = Developer Time + Regression Testing + Dual Maintenance + Training + Opportunity Cost
Developer Time: 25% of actual cost
Regression Testing: 35% of actual cost
Dual Maintenance: 20% of actual cost
Training: 10% of actual cost
Opportunity Cost: 10% of actual cost

I’ll break down each cost category and show you what actually happens during a migration.

The Hidden Costs Nobody Talks About

1. Dual Maintenance During Transition

When we started, we thought we’d just stop developing C++ and write Rust. Reality hit hard.

During a migration, you have two codebases running simultaneously:

Month 1-6: 100% C++ features + 0% Rust
Month 7-12: 100% C++ features + 20% Rust (duplicated effort)
Month 13-18: 100% C++ features + 40% Rust (duplicated effort)
Month 19-24: 100% C++ features + 60% Rust (still duplicating)

Every new feature had to be implemented twice: once in C++ for customers who need it now, once in Rust for the future. Bug fixes had to be backported. API changes had to be synchronized.

The math is brutal:

Feature implementation cost:
- C++ only: 1.0x effort
- Rust only: 1.2x effort (learning curve)
- Both: 2.2x effort (synchronization overhead)

For 18 months, we ran at 2.2x the normal feature development cost.

2. Regression Testing Explosion

We had 12,000 unit tests and 500 integration tests for our C++ codebase. We thought porting them would be straightforward.

legacy_test.cpp
TEST(DatabaseTest, InsertAndRetrieve) {
Database db(":memory:");
db.insert("key1", "value1");
auto result = db.get("key1");
ASSERT_EQ(result, "value1");
}

Simple, right? But each test had implicit assumptions about C++ behavior:

migrated_test.rs
#[test]
fn test_insert_and_retrieve() {
let db = Database::new_in_memory()?;
db.insert("key1", "value1")?;
let result = db.get("key1")?;
assert_eq!(result, Some("value1"));
}

The Rust version looks similar, but:

  • Memory model differences caused subtle test failures
  • Ownership semantics changed how mocks worked
  • Async behavior required different test infrastructure
  • Error handling patterns required complete rewrites

We spent 6 months just on test infrastructure before writing any production Rust code.

3. Developer Knowledge Gap

Our C++ team had 8 years of domain knowledge. Moving to Rust, that knowledge doesn’t transfer cleanly.

legacy_service.cpp
// C++ developer intuition: this is safe because we own the pointer
Service* get_service() {
static Service instance;
return &instance;
}
migrated_service.rs
// Rust: this lifetime annotation took 3 days to figure out
pub fn get_service() -> &'static Service {
static INSTANCE: OnceLock<Service> = OnceLock::new();
INSTANCE.get_or_init(Service::new)
}

The C++ developer knows the pattern is safe but lacks the vocabulary to express it in Rust. The Rust compiler demands explicit proof. This gap translates to:

Learning curve productivity:
Month 1-3: 30% of normal velocity
Month 4-6: 50% of normal velocity
Month 7-12: 70% of normal velocity
Month 13+: 85% of normal velocity (never fully recover)

Timeline Reality Check

For a 1-million-line C++ codebase, here’s what I’d estimate now:

Assessment and Planning: 3 months
Test Infrastructure Setup: 6 months
Core Library Migration: 12 months
Business Logic Migration: 18 months
Integration and Testing: 9 months
Documentation and Training: 6 months
Transition Support: 6 months
---
Total: 60 months (5 years)

And this assumes nothing goes wrong. Things that went wrong for us:

  • Build system incompatibility (CMake vs Cargo) - 2 month delay
  • Third-party library bindings - 4 month delay
  • Performance regression in hot paths - 3 month delay
  • Team turnover (2 senior devs left) - 4 month delay
  • Requirements changes during migration - ongoing delay

Real total: 60 months + 13 months of delays = 73 months (6+ years)

The Opportunity Cost Equation

The most painful realization: while we migrated, our competitors built features.

Year 1: Competitors ship 12 major features, we ship 3
Year 2: Competitors ship 10 major features, we ship 4
Year 3: Migration cancelled, we're 15 features behind

The opportunity cost formula:

Opportunity Cost = (Market Growth Rate) x (Features Not Built) x (Time)
For us:
= 15% annual market growth
x 18 features not built
x 3 years
= Lost market position we'll never recover

Risk Assessment Framework

When does migration make sense? Here’s the decision matrix I wish I had:

┌─────────────────────────────┬───────────────┬───────────────┐
│ Factor │ Migrate │ Don't Migrate │
├─────────────────────────────┼───────────────┼───────────────┤
│ Codebase size │ < 100K lines │ > 500K lines │
│ Team Rust experience │ > 50% │ < 20% │
│ Active feature development │ Minimal │ Heavy │
│ Memory safety incidents │ Frequent │ Rare │
│ Performance requirements │ Strict safety │ Raw speed │
│ Third-party dependencies │ Rust-friendly │ C++-only │
│ Timeline pressure │ None │ Any │
│ Legacy system lifetime │ 5+ years │ < 3 years │
└─────────────────────────────┴───────────────┴───────────────┘

Score: Migrate if you check 6+ boxes in the “Migrate” column.

Our score: 2/8. We should never have started.

Interoperability Patterns: The Pragmatic Alternative

Instead of full migration, we should have used incremental Rust integration. Here’s how.

Option 1: FFI (Foreign Function Interface)

Call Rust from C++ directly:

lib.rs
use std::ffi::{CString, CStr};
use std::os::raw::c_char;
#[no_mangle]
pub extern "C" fn process_data(input: *const c_char) -> *mut c_char {
let c_str = unsafe { CStr::from_ptr(input) };
let rust_str = c_str.to_str().unwrap();
// Rust processing logic
let result = format!("processed: {}", rust_str);
CString::new(result).unwrap().into_raw()
}
#[no_mangle]
pub extern "C" fn free_string(s: *mut c_char) {
unsafe {
if s.is_null() { return; }
CString::from_raw(s);
}
}
main.cpp
#include <iostream>
extern "C" {
char* process_data(const char* input);
void free_string(char* s);
}
int main() {
const char* input = "hello world";
char* result = process_data(input);
std::cout << result << std::endl; // "processed: hello world"
free_string(result);
return 0;
}

Build with:

Build commands
# Build Rust library
cargo build --release
# Link with C++
g++ main.cpp -L target/release -lmylib -o app

This works but is error-prone. The unsafe blocks and manual memory management defeat Rust’s safety guarantees.

Use cxx for safe Rust-C++ interoperability:

src/lib.rs
#[cxx::bridge]
mod ffi {
extern "C++" {
include!("wrapper.h");
fn cpp_process(input: &str) -> String;
}
extern "Rust" {
fn rust_process(input: &str) -> String;
}
}
fn rust_process(input: &str) -> String {
format!("Rust processed: {}", input)
}
fn call_cpp_from_rust() {
let result = ffi::cpp_process("test data");
println!("Got from C++: {}", result);
}
wrapper.h
#pragma once
#include <string>
std::string cpp_process(const std::string& input) {
return "C++ processed: " + input;
}
Cargo.toml
[dependencies]
cxx = "1.0"
[lib]
crate-type = ["staticlib"]

The cxx crate provides:

  • Automatic type conversion (Rust String <-> C++ std::string)
  • Safe memory management across the boundary
  • Build system integration with CMake
  • No unsafe blocks required in most cases

Option 3: Incremental Migration Strategy

Instead of rewriting, wrap critical components:

incremental/wrapped_database.rs
use cxx::CxxString;
#[cxx::bridge]
mod ffi {
extern "C++" {
include!("database.h");
type Database;
fn new_database() -> UniquePtr<Database>;
fn insert(self: &Database, key: &CxxString, value: &CxxString) -> bool;
fn get(self: &Database, key: &CxxString) -> UniquePtr<CxxString>;
}
}
// Rust wrapper with idiomatic interface
pub struct SafeDatabase {
inner: cxx::UniquePtr<ffi::Database>,
}
impl SafeDatabase {
pub fn new() -> Self {
Self {
inner: ffi::new_database(),
}
}
pub fn insert(&self, key: &str, value: &str) -> Result<(), Error> {
let cxx_key = cxx::CxxString::from_str(key)?;
let cxx_value = cxx::CxxString::from_str(value)?;
if self.inner.insert(&cxx_key, &cxx_value) {
Ok(())
} else {
Err(Error::InsertFailed)
}
}
pub fn get(&self, key: &str) -> Option<String> {
let cxx_key = cxx::CxxString::from_str(key).ok()?;
self.inner.get(&cxx_key).map(|s| s.to_string())
}
}

This approach lets you:

  1. Keep C++ code running in production
  2. Add Rust for new features only
  3. Gradually wrap existing C++ components
  4. Avoid the big-bang rewrite risk

What I’d Do Differently

If I could restart, here’s the approach:

Phase 1: Assessment (1 month)

- Count lines of code by component
- Identify memory safety hotspots
- Survey team Rust experience
- Analyze third-party dependencies
- Calculate actual migration timeline

Phase 2: Pilot (3 months)

- Pick one isolated component
- Write new features in Rust
- Set up FFI bridges
- Measure productivity impact
- Decide: continue or stop

Phase 3: Incremental Adoption (ongoing)

- Only new code in Rust
- Wrap existing C++ as needed
- No forced migration
- Let it happen organically

Summary

In this post, I showed how our C++ to Rust migration failed because we underestimated the true costs: dual maintenance during transition, regression testing infrastructure, developer learning curves, and opportunity cost of features not built.

The key insight is that full migration is rarely the right approach. For large codebases, incremental adoption via FFI or cxx bridges provides most of Rust’s benefits with a fraction of the cost and risk.

Before starting a migration, ask yourself: is this a business decision or a technology decision? Technology decisions can be reversed. Business decisions—like losing 3 years of feature development—cannot.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments