Skip to content
C++
Idiom
since C++11
Intermediate

Copy-on-Write

Defer expensive deep copies until a mutation is actually needed by sharing immutable state with a reference count and cloning only when a write occurs.

Copy-on-Write (COW)since C++98

A resource management strategy where copies share the same underlying data until one copy is modified β€” at which point the modifying copy silently clones the shared state so the other copies remain unaffected.

Overview

Copy-on-write (COW), also called lazy copying, defers the cost of a deep copy until it is strictly necessary. When you copy a COW object, both the original and the copy point at the same underlying data. Only when one of them attempts a mutation does the write side clone the data, making its own private copy before applying the change.

The result is that read-heavy workloads β€” where objects are copied but rarely modified β€” pay the cost of a pointer copy instead of a full data copy. The tradeoff: every write must first check whether the data is shared, and if so, clone it. This extra check and occasional clone can actually hurt performance in write-heavy or single-owner scenarios.

Historically, std::string used COW internally on some implementations (GCC before C++11). The C++11 standard banned COW strings because the required locking overhead for thread safety cancelled the benefit. Today COW is best applied explicitly, in application code, when profiling shows that particular objects are copied far more often than mutated.

Syntax

The standard pattern uses std::shared_ptr as the reference-counting backbone:

cpp
#include <memory>

template<typename T>
class Cow {
public:
    explicit Cow(T value)
        : data_(std::make_shared<T>(std::move(value))) {}

    // Read access β€” no clone needed
    const T& read() const { return *data_; }

    // Write access β€” clone if shared
    T& write() {
        if (data_.use_count() > 1)
            data_ = std::make_shared<T>(*data_);  // clone
        return *data_;
    }

private:
    std::shared_ptr<T> data_;
};

With this shell, copies are O(1) (pointer copy + reference count bump), and writes trigger at most one clone.

Examples

Basic Usage

cpp
#include <iostream>
#include <memory>
#include <string>

template<typename T>
class Cow {
public:
    explicit Cow(T v) : data_(std::make_shared<T>(std::move(v))) {}

    const T& read() const { return *data_; }

    T& write() {
        if (data_.use_count() > 1)
            data_ = std::make_shared<T>(*data_);
        return *data_;
    }

private:
    std::shared_ptr<T> data_;
};

int main() {
    Cow<std::string> original("hello");
    Cow<std::string> copy = original;   // O(1) β€” both share the same string

    std::cout << original.read() << '\n';  // hello
    std::cout << copy.read()    << '\n';   // hello (same memory)

    copy.write() += " world";             // clone triggered here

    std::cout << original.read() << '\n'; // hello   (unchanged)
    std::cout << copy.read()    << '\n';  // hello world
}

Class-Embedded COW (Transparent to the Caller)

The idiom is most ergonomic when hidden inside the class:

cpp
#include <memory>
#include <string>
#include <vector>

class Document {
public:
    explicit Document(std::string text)
        : impl_(std::make_shared<Impl>(std::move(text))) {}

    // Read operations β€” no clone
    const std::string& text() const { return impl_->text; }
    std::size_t word_count() const  { return impl_->words.size(); }

    // Write operation β€” clone if shared
    void append(const std::string& more) {
        detach();
        impl_->text += more;
    }

private:
    struct Impl {
        std::string text;
        std::vector<std::string> words;  // parsed index, expensive to rebuild
    };

    void detach() {
        if (impl_.use_count() > 1)
            impl_ = std::make_shared<Impl>(*impl_);
    }

    std::shared_ptr<Impl> impl_;
};

Document original("The quick brown fox");
Document cached = original;   // cheap snapshot; shared state

original.append(" jumps");    // detach() clones impl_ for original
                              // cached.impl_ still points at old state

The Reference-Escape Trap

A critical pitfall of generic COW wrappers: returning a reference to internal state before cloning defeats the protection entirely.

cpp
Cow<std::string> s1("hello");
Cow<std::string> s2 = s1;   // shared

// DANGER: taking a non-const ref doesn't trigger clone until write() is called
// but the ref may outlive the clone decision:
char& c = s1.write()[4];   // write() clones here β€” OK so far
s2.write();                // s2 also clones β€” both independent now
c = '!';                   // safe: s1's private copy

// But if you do:
// char& bad = s1.read()[4];  // no clone
// s2.write() += " world";    // s2 clones, but s1 still shares OLD data
// bad = '!';                 // modifies data still shared with... nobody (already detached)
//                            // but the pattern is fragile and hard to reason about

The lesson: expose COW semantics through value-returning write methods rather than reference-returning accessors when possible.

Best Practices

  • Profile before adopting: COW adds reference-count overhead and conditional branches to every write. Use it only when copies are frequent and mutations rare.
  • Encapsulate COW in the class, not in a generic wrapper: class-level detach() methods can clone exactly the right pieces and avoid accidental reference escapes.
  • Pair with std::shared_ptr rather than a hand-rolled reference count: the standard pointer handles thread safety for the pointer itself (not the pointee data).
  • Mark read methods const and write methods explicit: the const/non-const split is the natural API for COW β€” read through const T&, write through a detach()-and-modify pattern.
  • Avoid COW for types with mutation-yielding reference accessors: any T& returned from inside the COW object can produce aliasing bugs like the one shown above.

Common Pitfalls

Thread safety is only partial: std::shared_ptr's reference count is atomic, so copying or assigning the shared_ptr itself is safe across threads. But the data it points to is not protected. If two threads both call write(), they may both see use_count() > 1 and both clone β€” wasting work β€” or, in a race, one may mutate data the other is still reading. Add explicit synchronisation around mutations if threads share COW objects.

use_count() == 1 is not reliable under concurrency: between the check and the mutation, another thread could create a new copy, making the count jump. use_count() is advisory only; do not use it as a lock.

COW strings were removed from the C++ standard for a reason: std::string's COW was banned in C++11 because any iterator or pointer invalidation rule becomes undefined under the sharing model, and correctness-preserving locking negated the performance benefit. Keep the same lesson in mind for your own types.

Full CowBuffer Implementation

The sketch in the Syntax section omits several operations a real type needs. Here is a complete CowBuffer β€” a byte array with COW semantics β€” that covers construction, copy, move, and the detach() helper that all mutating methods delegate to.

cpp
#include <algorithm>
#include <cstddef>
#include <cstring>
#include <memory>
#include <stdexcept>

class CowBuffer {
public:
    // Construct from raw bytes
    CowBuffer(const std::byte* src, std::size_t n)
        : data_(make(src, n)), size_(n) {}

    // Value constructor β€” allocate zeroed storage
    explicit CowBuffer(std::size_t n)
        : data_(std::make_shared<std::byte[]>(n)), size_(n) {}

    // Copy β€” O(1): share the allocation
    CowBuffer(const CowBuffer&) = default;
    CowBuffer& operator=(const CowBuffer&) = default;

    // Move β€” O(1): take ownership, leave source empty
    CowBuffer(CowBuffer&&) noexcept = default;
    CowBuffer& operator=(CowBuffer&&) noexcept = default;

    // Read access β€” no clone
    const std::byte* data() const noexcept { return data_.get(); }
    std::size_t      size() const noexcept { return size_; }

    std::byte at(std::size_t i) const {
        if (i >= size_) throw std::out_of_range("CowBuffer::at");
        return data_.get()[i];
    }

    // Write access β€” clone if shared
    std::byte& at_mut(std::size_t i) {
        if (i >= size_) throw std::out_of_range("CowBuffer::at_mut");
        detach();
        return data_.get()[i];
    }

    void write(std::size_t offset, const std::byte* src, std::size_t n) {
        if (offset + n > size_) throw std::out_of_range("CowBuffer::write");
        detach();
        std::memcpy(data_.get() + offset, src, n);
    }

    // After detach(), use_count() == 1 β€” safe to mutate
    bool is_shared() const noexcept { return data_.use_count() > 1; }

private:
    std::shared_ptr<std::byte[]> data_;
    std::size_t                  size_;

    // Clone the underlying allocation when we are not the sole owner
    void detach() {
        if (data_.use_count() > 1) {
            auto fresh = std::make_shared<std::byte[]>(size_);
            std::memcpy(fresh.get(), data_.get(), size_);
            data_ = std::move(fresh);
        }
    }

    static std::shared_ptr<std::byte[]> make(const std::byte* src, std::size_t n) {
        auto p = std::make_shared<std::byte[]>(n);
        std::memcpy(p.get(), src, n);
        return p;
    }
};

Key design points:

  • detach() is private and called unconditionally before any mutation. Callers do not need to remember the check.
  • Read methods are const and never call detach(), so multiple threads can call them concurrently without any synchronisation beyond what std::shared_ptr's reference count already provides.
  • std::shared_ptr&lt;T[]&gt; (array form, C++17) is used so that a single smart pointer manages the allocation without a custom deleter.
  • is_shared() is provided for diagnostic and testing purposes β€” never use it as a gate for mutation decisions in multithreaded code (see the next section).

The unique() Deprecation

Older COW code often wrote:

cpp
if (!data_.unique())          // C++11 / C++14
    data_ = std::make_shared<Data>(*data_);

std::shared_ptr::unique() was deprecated in C++17 and removed in C++20 because it conveys a false sense of safety: the result is stale the moment you read it. The equivalent today is an explicit comparison:

cpp
if (data_.use_count() != 1)   // C++17+; same caveat applies
    data_ = std::make_shared<Data>(*data_);

The != 1 form is semantically identical; neither form is safe for concurrent mutation without external synchronisation. The deprecation was a standards-committee signal that use_count() is a diagnostic tool, not a synchronisation primitive.

Thread Safety: The TOCTOU Race

std::shared_ptr's reference count is maintained with atomic operations, so copying or destroying a shared_ptr across threads is safe. The data the pointer refers to is a completely separate matter.

The Race Window

Consider two threads each holding a CowBuffer copy and each about to call a mutating method:

cpp
// Thread A                         // Thread B
//
// Both see use_count() == 2
if (buf.is_shared()) {              if (buf.is_shared()) {
    // A decides to clone ...           // B decides to clone ...
    buf.detach_internal();              buf.detach_internal();
}
// A mutates its private copy      // B mutates its private copy

Because the check (use_count() > 1) and the clone are two separate operations, another thread can alter the reference count between them. This is a classic time-of-check / time-of-use (TOCTOU) race. Possible outcomes:

  1. Double clone (wasted work): Both threads see count 2, both clone, both end up with private copies. Correct but inefficient.
  2. Missed clone (data race): Thread A checks, sees count 1 (thinks it is sole owner), skips clone, and begins writing β€” while Thread B simultaneously reads the same memory. This is undefined behaviour.

Scenario 2 is the dangerous one. It can occur whenever a shared_ptr is concurrently copied on one thread while another thread uses use_count() to decide whether to mutate.

Safe Approaches Under Concurrency

Option 1 β€” Mutual exclusion around the detach-and-mutate pair.

cpp
class ThreadSafeCow {
public:
    void mutate(auto fn) {
        std::lock_guard lock(mu_);
        if (data_.use_count() > 1)
            data_ = std::make_shared<Data>(*data_);
        fn(*data_);
    }

    const Data& read() const {
        // Callers must ensure no concurrent mutate() is in flight,
        // or wrap this in a shared_lock for reader-writer locking.
        return *data_;
    }

private:
    mutable std::mutex mu_;
    std::shared_ptr<Data> data_;
};

The lock ensures the check and the potential clone are atomic with respect to other threads that hold the same lock.

Option 2 β€” Always copy before mutating (snapshot model).

If each thread works on its own shared_ptr copy and never shares a CowBuffer between threads after creation, the TOCTOU window does not exist: the count will always be >= 2 from the perspective of the mutating thread (because other threads hold copies), so it will always clone. This is the safest mental model: treat COW as a copy-avoidance optimisation for sequential scenarios only.

Option 3 β€” Atomic pointer swap (lock-free publish).

For a single-writer / many-reader pattern, store the shared_ptr in a std::atomic&lt;std::shared_ptr&lt;Data&gt;&gt; (C++20) and use compare-exchange to publish a new snapshot:

cpp
std::atomic<std::shared_ptr<Data>> current;

void publish_update(auto fn) {
    std::shared_ptr<Data> old_snap = current.load();
    std::shared_ptr<Data> new_snap;
    do {
        new_snap = std::make_shared<Data>(*old_snap);
        fn(*new_snap);
    } while (!current.compare_exchange_weak(old_snap, new_snap));
}

Readers call current.load() and use the snapshot for its lifetime β€” no lock needed. The writer retries if another writer raced it to the swap. This pattern is the basis of lock-free versioned state in high-performance systems.

std::string COW: History and Prohibition

Before C++11

GCC's libstdc++ implemented std::string as a COW type from the early 1990s until GCC 5 (released 2015). The internal layout stored a reference count immediately before the character array; copying a string incremented the count and shared the buffer. On x86 with a single thread this was measurably faster for programs that passed strings by value frequently.

Why C++11 Banned It

The C++11 standard committee explicitly prohibited COW strings. The relevant rule is in [string.require]: the standard requires that operator[], at(), front(), back(), begin(), end(), and data() do not invalidate iterators or references on non-mutating calls. For a COW string, returning a non-const reference from operator[] could trigger an implicit clone β€” potentially invalidating the reference the caller holds β€” or it would have to skip the clone and risk aliasing if the caller modified through the reference.

The committee's options were:

  1. Make operator[] always trigger a clone (pessimises read-only subscript access).
  2. Track individual character mutations through a proxy object (unacceptable ABI cost).
  3. Ban COW strings entirely.

They chose option 3. [res.on.data.races] further requires that all standard library operations on a shared const object be data-race-free. A COW string's implicit clone on first non-const access modifies the internal reference count and pointer β€” shared mutable state that cannot be made data-race-free without per-access locking.

The Practical Consequence

If you compiled code with GCC 4.x and linked it against GCC 5+ or clang (which used non-COW strings), the two std::string ABIs were binary-incompatible. GCC introduced the _GLIBCXX_USE_CXX11_ABI macro (defaulting to 1 on GCC 5+) to let projects opt in to the new non-COW ABI. Mixed builds β€” part of a project compiled with the old ABI, part with the new β€” produced subtle, hard-to-diagnose corruption. This real-world pain is the practical legacy of hidden COW.

The lesson for your own types: COW semantics that are invisible at the API level (no explicit read()/write() split) are a long-term maintenance liability.

When COW Helps vs Hurts

COW Helps

ScenarioWhy COW wins
AST nodes copied for speculative analysisParse once, share across passes; mutations are rare and localised
Configuration objects distributed to many subsystemsEach subsystem holds a snapshot; a config reload creates one new allocation rather than N copies
Large immutable records passed through a pipelineIntermediate stages read without cloning; only the stage that needs to modify allocates
Undo / redo historyEach edit produces a new allocation for the changed node; older states stay alive cheaply

The common thread: many readers, occasional writers, objects that are expensive to copy in full.

COW Hurts

ScenarioWhy COW loses
Write-heavy pipelinesEvery write triggers a clone; the reference-count check adds a branch with a potential cache miss
Small objectsA shared_ptr control block (typically 16–24 bytes) plus the heap allocation dwarfs the benefit for a 4-byte int or a short string
Single-owner objects that happen to be type-aliased through COWuse_count() is always 1; the check is pure overhead
Fine-grained mutations in a tight loopEach mutation is a potential clone; memory allocator pressure mounts quickly

A quick profiling heuristic: if the detach() path (the clone) is visible on a flame graph, the workload is write-heavy enough that COW is probably making things worse.

Cache Effects

When a clone does occur, two threads (or two parts of the same thread's execution) now have pointers to different cache lines. This false sharing is gone β€” both threads work on private data. But the cost of the clone itself (a malloc + memcpy) can exceed the cache-miss penalty of the original sharing model for small objects. Measure with perf or a microbenchmark before committing.

Modern Alternative: Persistent / Immutable Data Structures

COW is a specific instance of a broader technique called structural sharing: rather than copying an entire data structure on modification, you create a new version that re-uses unchanged parts of the old version.

COW's structural sharing is coarse-grained β€” the entire payload is shared until the first write, then the entire payload is copied. Persistent (or functional) data structures take this further by sharing at the node level:

cpp
Original list:   [A] β†’ [B] β†’ [C] β†’ [D]
                              ↑
Modified list:   [A'] β†’ [B'] β”˜   (only A and B are new nodes; C and D are shared)

Clojure's persistent vectors, Haskell's Data.Map, and C++ libraries such as immer implement this pattern. A modification to a persistent tree of depth d costs O(d) new allocations rather than O(n) for a full copy.

COW vs persistent structures:

PropertyCOWPersistent / Immutable
Sharing granularityWhole payloadNode-level
Mutation costO(n) clone the first timeO(log n) path copy
Read costO(1) β€” direct pointerO(log n) β€” tree traversal
Thread safetyNeeds external sync for mutationsOld versions remain valid; readers need no sync
ComplexityLow β€” one shared_ptrHigher β€” tree discipline required

COW is the right choice when the data is either small enough that a full clone is cheap, or when writes are rare enough that the O(n) clone almost never happens. Persistent structures win when mutations are common and you need multiple versions to coexist without copying.

See Also

  • Counted Pointer β€” the reference-counting primitive that COW relies on
  • Pimpl Idiom β€” similar "opaque pointer to implementation" structure; often combined with COW
  • Value Semantics β€” the broader philosophy of types that behave like values; COW is one implementation strategy