C++ shared_ptr vs unique_ptr: Ownership, Overhead, Thread Safety and When to Use Each

Key takeaways

shared_ptr vs unique_ptr: prefer unique_ptr by default; use shared_ptr for shared ownership. Reference counting cost, weak_ptr for cycles, and performance-minded patterns.

Why Smart Pointers?

Raw pointers in C++ do not communicate ownership. Who frees the memory? Who is allowed to use the pointer after a function call? These questions cause memory leaks, use-after-free, and double-free bugs.

Smart pointers from <memory> make ownership explicit and automatic:

// Raw pointer — ownership unclear
int* raw = new int(42);
delete raw;  // remember to delete, no double-delete, no forget

// unique_ptr — single owner, deleted automatically
std::unique_ptr<int> u = std::make_unique<int>(42);
// deleted when u goes out of scope — no manual delete

// shared_ptr — reference counted, deleted when last owner is gone
std::shared_ptr<int> s = std::make_shared<int>(42);
auto s2 = s;  // refcount = 2
// deleted when both s and s2 go out of scope

The difference between them is ownership semantics, not just syntax.


Ownership Models

unique_ptr: Exclusive Ownership

unique_ptr expresses that exactly one object owns the resource. You cannot copy it — only move it. When the unique_ptr is destroyed, the object is deleted.

#include <memory>
#include <iostream>

struct Widget {
    int id;
    Widget(int id) : id(id) { std::cout << "Widget " << id << " created\n"; }
    ~Widget() { std::cout << "Widget " << id << " destroyed\n"; }
};

int main() {
    auto w = std::make_unique<Widget>(1);    // created
    // auto w2 = w;                          // compile error — cannot copy

    auto w2 = std::move(w);                  // ownership transferred
    // w is now null — w2 owns the Widget

    if (!w) std::cout << "w is empty\n";    // w is empty
    std::cout << "w2 owns Widget " << w2->id << '\n';
}
// Widget 1 destroyed — when w2 goes out of scope

This is the most common smart pointer — use it whenever a resource has a single clear owner.

The deleted copy constructor is the whole point. With a raw pointer, Widget* w2 = w; silently creates a second path to the same object, and nothing in the code says which one must delete it. With unique_ptr, the compiler refuses the copy (error: use of deleted function 'std::unique_ptr<...>::unique_ptr(const std::unique_ptr<...>&)'), and the only way to hand the object over is an explicit std::move, which leaves the source empty. A moved-from unique_ptr is guaranteed to be null, so the if (!w) check is well defined; dereferencing it is not, and is the usual source of crashes after a move.

shared_ptr: Shared Ownership

shared_ptr allows multiple owners. A reference count in the control block tracks how many shared_ptr objects point to the resource. The resource is deleted when the last shared_ptr to it is destroyed.

#include <memory>
#include <iostream>

int main() {
    auto shared = std::make_shared<Widget>(2);  // refcount = 1
    std::cout << "count: " << shared.use_count() << '\n';  // 1

    {
        auto copy1 = shared;  // refcount = 2
        auto copy2 = shared;  // refcount = 3
        std::cout << "count: " << shared.use_count() << '\n';  // 3
    }  // copy1 and copy2 destroyed — refcount = 1

    std::cout << "count: " << shared.use_count() << '\n';  // 1
}
// Widget 2 destroyed — last owner (shared) goes out of scope

use_count() is useful for demonstrations and debugging but should not drive program logic: in multi-threaded code the value can change between reading it and acting on it. The more important consequence of shared ownership is that when the object is destroyed is no longer visible in the code. It happens wherever the last copy happens to be released, which may be a different thread, inside a callback, or much later than expected because a cache or a lambda capture kept a copy. For objects that hold files, sockets or locks, that uncertainty is often a bigger problem than the reference-counting cost.

weak_ptr: Non-Owning Observer

weak_ptr observes a shared_ptr-managed object without participating in ownership. It does not keep the object alive.

#include <memory>
#include <iostream>

int main() {
    std::shared_ptr<Widget> owner = std::make_shared<Widget>(3);
    std::weak_ptr<Widget> observer = owner;  // does not increment refcount

    std::cout << "owner count: " << owner.use_count() << '\n';  // 1

    // To use a weak_ptr, lock() it — returns shared_ptr or empty
    if (auto locked = observer.lock()) {
        std::cout << "Object still alive: " << locked->id << '\n';
    }

    owner.reset();  // Widget destroyed

    if (observer.expired()) {
        std::cout << "Object has been destroyed\n";
    }
}

Always go through lock() to use the object. Checking expired() and then using the object is a check-then-act race in multi-threaded code: the last owner can release it between the two calls. lock() checks and takes a temporary shared ownership in one atomic step, so the returned shared_ptr either holds a live object for as long as you keep it, or is empty. expired() is fine for “is it still there?” questions where a stale answer is harmless, such as pruning dead entries from an observer list.


Overhead Comparison

unique_ptr has zero overhead compared to a raw pointer — it is the same size and often compiles to identical machine code:

unique_ptr<T>:  [T*]          — same size as a raw pointer
                              — no heap allocation for ownership tracking
                              — custom deleter encoded in the type

shared_ptr<T>:  [T*, control*] — two pointers
                               — control block: refcount (atomic) + weakcount + deleter
                               — control block is a separate heap allocation unless make_shared

make_shared is more efficient than shared_ptr(new T(...)) because it allocates the object and control block in a single allocation:

// Two allocations: one for Widget, one for control block
std::shared_ptr<Widget> p1(new Widget(1));

// One allocation: Widget and control block together
std::shared_ptr<Widget> p2 = std::make_shared<Widget>(2);

The refcount updates in shared_ptr use atomic operations, which have measurable cost on multicore systems. In a tight loop copying and destroying shared_ptr, this can be slower than a similar unique_ptr workload. Profile before assuming it matters in your case.

Two qualifications to the table. The “same size as a raw pointer” claim holds for unique_ptr with the default deleter or any stateless deleter such as a captureless lambda; a function-pointer deleter (unique_ptr<FILE, int(*)(FILE*)>) stores that pointer too and doubles the size. And make_shared has a trade-off: because the object and the control block share one allocation, the object’s memory cannot be returned until the last weak_ptr is gone as well, even though the object’s destructor ran long before. For large objects observed by long-lived weak_ptrs (a cache, for example), constructing with shared_ptr<T>(new T(...)) keeps the memory separable.

The most common avoidable cost is passing shared_ptr by value to functions that only use the object. Each call increments and decrements the atomic count for nothing. Pass const T& or T* when the function does not keep the object, and const std::shared_ptr<T>& only when it might copy it conditionally.


Usage Patterns

Passing unique_ptr to Functions

// Sink: function takes ownership — pass by value, caller must move
void consume(std::unique_ptr<Widget> w) {
    std::cout << "consuming " << w->id << '\n';
    // w deleted when function returns
}

// Borrow: function uses but doesn't own — pass raw pointer or const reference
void inspect(const Widget& w) {
    std::cout << "inspecting " << w.id << '\n';
}

void inspectPtr(const Widget* w) {
    if (w) std::cout << "inspecting " << w->id << '\n';
}

int main() {
    auto w = std::make_unique<Widget>(10);

    inspect(*w);          // borrow by reference — w still owns
    inspectPtr(w.get());  // borrow by raw pointer — w still owns

    consume(std::move(w));  // transfer ownership — w is now null
    // w is null here
}

Factory Functions — Return unique_ptr

Factories that create objects should return unique_ptr. The caller can either keep it as unique_ptr, or upgrade to shared_ptr if needed:

std::unique_ptr<Widget> makeWidget(int id) {
    return std::make_unique<Widget>(id);  // clear: factory gives you ownership
}

int main() {
    // Keep as unique_ptr
    auto w = makeWidget(1);

    // Upgrade to shared_ptr if shared ownership is needed later
    std::shared_ptr<Widget> shared = makeWidget(2);
}

Custom Deleters

Both smart pointer types support custom deletion logic:

#include <cstdio>
#include <memory>

// unique_ptr with custom deleter — deleter is part of the type
auto fileCloser = [](FILE* f) { if (f) fclose(f); };
std::unique_ptr<FILE, decltype(fileCloser)> file(fopen("data.txt", "r"), fileCloser);

// shared_ptr with custom deleter — deleter is type-erased, not in the type
std::shared_ptr<FILE> sharedFile(fopen("data.txt", "r"), [](FILE* f) {
    if (f) fclose(f);
});

The difference: unique_ptr’s deleter is part of the template type (visible to callers), while shared_ptr’s deleter is stored in the control block and erased from the type. shared_ptr is more flexible here — you can store different deleters in the same shared_ptr<FILE> container.

The if (f) checks are not equally necessary. unique_ptr never calls its deleter on a null pointer, so the check in fileCloser is redundant there. shared_ptr, on the other hand, does invoke the deleter when it was constructed with a null pointer and a deleter, so if fopen fails, the lambda receives nullptr and must not pass it to fclose. Either way, check whether the file actually opened before using it.


Fixing Circular Reference with weak_ptr

The most common shared_ptr bug is a reference cycle. Two objects pointing to each other with shared_ptr keep each other alive forever — memory leak:

#include <memory>
#include <iostream>

struct Node {
    int value;
    std::shared_ptr<Node> next;   // shared_ptr to next
    std::shared_ptr<Node> prev;   // shared_ptr to previous — PROBLEM

    Node(int v) : value(v) {}
    ~Node() { std::cout << "Node " << value << " destroyed\n"; }
};

int main() {
    auto n1 = std::make_shared<Node>(1);
    auto n2 = std::make_shared<Node>(2);

    n1->next = n2;
    n2->prev = n1;  // cycle: n1 → n2 → n1

    // End of main: n1 refcount = 1 (n2->prev), n2 refcount = 1 (n1->next)
    // Neither reaches zero — LEAK. Neither destructor is called.
}

Fix: use weak_ptr on the back edge (the one that doesn’t need to keep the object alive):

struct Node {
    int value;
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev;  // weak_ptr — no ownership

    Node(int v) : value(v) {}
    ~Node() { std::cout << "Node " << value << " destroyed\n"; }
};

int main() {
    auto n1 = std::make_shared<Node>(1);
    auto n2 = std::make_shared<Node>(2);

    n1->next = n2;
    n2->prev = n1;  // weak_ptr — does not increment n1's refcount

    // End of main: locals are destroyed in reverse order.
    // n2 (stack) destroyed first: Node 2 refcount 2 → 1 (n1->next still owns it)
    // n1 destroyed: Node 1 refcount 1 → 0 → Node 1 destroyed
    // Node 1 releases next: Node 2 refcount 1 → 0 → Node 2 destroyed
}
// Output:
// Node 1 destroyed
// Node 2 destroyed

Cycles are the one kind of leak that smart pointers create rather than prevent, and they are easy to miss because no tool reports a “cycle” as such. LeakSanitizer and Valgrind report the nodes as leaked allocations, and the destructors that should print never run. The patterns that cause them are predictable: parent and child pointing at each other, observers that hold shared_ptrs to their subject, and lambdas that capture a shared_ptr to the object that stores the lambda. Deciding which direction owns (parent owns children, the subject does not own its observers) tells you which side becomes weak_ptr or a plain raw pointer.


Thread Safety

shared_ptr guarantees that reference count modifications (copying and destroying shared_ptr instances) are thread-safe. But the pointed-to object is not:

#include <iostream>
#include <memory>
#include <thread>
#include <mutex>

auto shared = std::make_shared<int>(0);
std::mutex mtx;

void increment() {
    // shared_ptr copy/destroy is thread-safe
    auto local = shared;  // safe — atomic refcount increment

    // BUT: modifying the pointed-to int is NOT thread-safe without synchronization
    std::lock_guard<std::mutex> lock(mtx);
    (*local)++;
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();
    std::cout << *shared << '\n';  // 2 — correct with mutex
}

The guarantee is narrower than “shared_ptr is thread-safe”. Different threads may copy, destroy and reset their own shared_ptr objects that point to the same thing, because the shared control block is updated atomically. Here each thread copies the global shared into local while nobody writes to shared itself, which is allowed. What is not allowed is two threads accessing the same shared_ptr object when at least one of them modifies it, for example one thread doing shared = std::make_shared<int>(5); while another copies shared. That is an ordinary data race on the pointer object and undefined behavior. For a global pointer that is swapped at runtime (a configuration snapshot, say), use a mutex around the pointer or C++20’s std::atomic<std::shared_ptr<T>>.


Selection Guide

SituationUse
Single clear owner, resource returned from factoryunique_ptr
Multiple independent owners that outlive each othershared_ptr
Observer: needs access but not ownershipweak_ptr or raw reference
Resource with custom cleanup (file, socket, lock)unique_ptr with deleter
Doubly-linked structure, parent-child with back-pointershared_ptr forward, weak_ptr back
Polymorphic base class pointerunique_ptr<Base> or shared_ptr<Base>
Container of heterogeneous owned objectsvector<unique_ptr<Base>>

Default rule: reach for unique_ptr first. Upgrade to shared_ptr only when the use case genuinely requires shared ownership — when it’s difficult to define a single owner with a clear lifetime.


enable_shared_from_this

When an object managed by shared_ptr needs to create a new shared_ptr to itself (common in async callbacks), use enable_shared_from_this:

#include <memory>
#include <iostream>

class Session : public std::enable_shared_from_this<Session> {
public:
    void start() {
        // shared_from_this() returns a shared_ptr to this — safe
        auto self = shared_from_this();
        // pass self to async callback to keep Session alive
        doAsync([self]() {
            std::cout << "async work on Session\n";
        });
    }

    template<typename F>
    void doAsync(F f) { f(); }
};

int main() {
    auto session = std::make_shared<Session>();
    session->start();
}

Never call shared_from_this() in a constructor — the shared_ptr managing the object doesn’t exist yet.

Since C++17, calling shared_from_this() on an object that is not owned by any shared_ptr (a stack object, or one created with plain new, or from the constructor) throws std::bad_weak_ptr; before C++17 it was undefined behavior. The inheritance must also be public, otherwise make_shared cannot find the hidden weak_ptr and shared_from_this() fails the same way. The pattern exists because the naive alternative, std::shared_ptr<Session>(this), creates a second control block for the same object, and the object is then deleted twice. In asynchronous code such as Asio handlers, capturing self is what keeps a Session alive until its last pending callback has run; forgetting it is a classic source of use-after-free crashes when a connection closes while an operation is still in flight.


Ownership rules to default to

  • Default to unique_ptr — zero overhead, clear ownership, move-only semantics prevent accidental sharing
  • Use shared_ptr only when ownership is genuinely shared with independently-scoped owners
  • make_unique / make_shared are preferred: exception-safe and (for make_shared) a single allocation
  • weak_ptr breaks reference cycles and allows non-owning observation — always lock before use
  • Thread safety: shared_ptr copy/destroy is thread-safe; the pointed-to object is not
  • Custom deleters: unique_ptr encodes in the type; shared_ptr type-erases it in the control block
  • Factory functions should return unique_ptr — callers can upgrade to shared_ptr if needed

Frequently Asked Questions (FAQ)

Q. Can I start with unique_ptr and switch to shared_ptr later if ownership becomes shared?

A. Yes. A std::shared_ptr<T> can be constructed from a std::unique_ptr<T>&&, so std::shared_ptr<Widget> sp = std::move(up); or passing the result of a factory that returns unique_ptr both work, and the custom deleter is carried over. The reverse direction is impossible, because a shared_ptr cannot give up ownership that others may share. This is why factory functions return unique_ptr: the caller keeps the option to share. The converted pointer does get a separately allocated control block, which make_shared would have avoided.