Skip to content
C++

When NOT to Use Smart Pointers

Smart pointers are the right tool for expressing ownership. But not every use of a pointer involves ownership — and reaching for a smart pointer when you don't need one imposes unnecessary constraints on callers, adds indirection, and in the case of shared_ptr, carries real runtime overhead. Understanding when raw pointers and references remain the correct choice is as important as knowing how to use smart pointers.

Ownership vs. access — the fundamental distinction

Raw pointers are not inherently dangerous. As the C++ Core Guideline I.11 explains, the specific problem with raw pointers is transferring ownership through them — returning a heap-allocated object by raw pointer and expecting the caller to call delete. That implicit contract is unenforceable, undocumentable in the type system, and the historical source of nearly every memory leak and use-after-free in C++.

A raw pointer used purely for non-owning access — looking at an object whose lifetime is managed elsewhere — carries none of these risks. If you take a Widget*in a function that just reads the widget and returns, no ownership was transferred, no lifetime was extended, and nothing can go wrong that wasn't already wrong before the call. The guideline, correctly read, is not “never use raw pointers” but “never transfer ownershipby a raw pointer.” Core Guideline R.3 formalises this: a raw pointer (T*) is non-owning. It is the reader of an object, not its custodian.

// ✗ Raw pointer transferring ownership — the banned pattern
Wish* make_a_wish(int id) {
    return new Wish(wishes[id]);  // caller must delete — undefined responsibility
}

// ✓ Smart pointer to express ownership clearly
std::unique_ptr<Wish> make_a_wish(int id) {
    return std::make_unique<Wish>(wishes[id]);  // ownership is explicit and enforced
}

// ✓ Raw pointer as non-owning observer — perfectly fine
size_t measure_widget(Widget* w) {
    return w->size();  // just reads — no ownership concern
}

// ✓ Reference as non-owning observer — even better (cannot be null)
size_t measure_widget(const Widget& w) {
    return w.size();   // caller knows they must keep w alive; null impossible
}

Non-owning function parameters

The most common legitimate use of raw pointers in modern C++ is as function parameters that do not take ownership. Core Guideline F.7 says: “For general use, take T* or T&arguments rather than smart pointers.” The reason is that taking a shared_ptr<T> by value as a parameter forces the caller to provide a shared_ptr — they cannot pass a stack variable, a reference, or a unique_ptr-owned object without awkward conversion. You have needlessly constrained the interface.

The decision between T& and T*depends on whether null is a meaningful input. References cannot be null (without undefined behaviour), so T& is a stronger contract: the caller is forced to check for null before calling. T*is appropriate when null legitimately means “no argument” and the function handles that case.

void f(const T& t)

Argument is always non-null, function doesn't store it. Best default for read-only access.

void f(T& t)

Function may modify t. Non-null guaranteed. Can't accidentally pass null.

void f(T* t)

Null is a valid 'no argument' case. Function checks for null internally. Non-owning.

void f(const shared_ptr<T>& t)

Function might conditionally store t (take shared ownership) — rare. Caller must use shared_ptr.

void f(shared_ptr<T> t)

Function always takes shared ownership. Callers must have a shared_ptr.

void f(unique_ptr<T> t)

Function takes exclusive ownership (sink). Caller loses access after the call.

// Problematic: takes shared_ptr by value — constrains all callers
void draw(std::shared_ptr<Widget> w) { w->render(); }

Widget stack_widget;
draw(&stack_widget);                        // ✗ won't compile
draw(std::make_shared<Widget>());           // ✓ works, but created a shared_ptr just for this

// Correct: take by reference — works with any Widget, regardless of how it's managed
void draw(const Widget& w) { w.render(); }

Widget stack_widget;
draw(stack_widget);                         // ✓ stack variable
auto up = std::make_unique<Widget>();
draw(*up);                                  // ✓ dereference unique_ptr
auto sp = std::make_shared<Widget>();
draw(*sp);                                  // ✓ dereference shared_ptr

Back-pointers and graph structures

In tree or graph structures, ownership typically flows in one direction: a parent owns its children via unique_ptr or holds them in a vector<unique_ptr<Node>>. Children sometimes need to refer back to their parent — for example, to navigate upward in a parse tree or to access shared configuration. Using a shared_ptrfor the child-to-parent link would create a reference cycle: the parent's reference count would never reach zero because the child holds a reference to it, and the child's count would never reach zero because the parent holds the child. The entire structure leaks.

A raw pointer back to the parent is the correct solution here. The parent owns the child (via unique_ptr), so the parent is guaranteed to outlive the child. The child's raw pointer to the parent is therefore always valid during the child's lifetime — no ownership transfer, no cycle, no leak.

struct Node {
    std::string value;
    std::vector<std::unique_ptr<Node>> children;  // owning: parent outlives children
    Node* parent = nullptr;                        // non-owning back-pointer — correct

    // ✗ Wrong: shared_ptr back-pointer creates a reference cycle → memory leak
    // std::shared_ptr<Node> parent;
};

void build_tree(Node& root) {
    auto child = std::make_unique<Node>();
    child->parent = &root;          // safe: root always outlives its children
    root.children.push_back(std::move(child));
}

// ✓ Alternatively: weak_ptr breaks the cycle if shared_ptr ownership is genuinely needed
struct SharedNode {
    std::vector<std::shared_ptr<SharedNode>> children;
    std::weak_ptr<SharedNode> parent;  // weak_ptr: non-owning, doesn't prevent destruction
};

Iterating over smart pointer containers

When you have a vector<unique_ptr<T>> and need to pass elements to a function, you do not copy the unique_ptr(it can't be copied) and you should not move it out (that would empty your container). The right approach is to dereference to get a T&, or to call .get()to extract a raw pointer, and pass that. The object's lifetime is tied to the unique_ptr in the container — as long as the container is alive, the raw pointer is valid.

std::vector<std::unique_ptr<Widget>> widgets;
widgets.push_back(std::make_unique<Widget>());
widgets.push_back(std::make_unique<Widget>());

// Iterating: dereference to get Widget& (preferred)
for (const auto& up : widgets) {
    draw(*up);                // *up is Widget& — clean and idiomatic
}

// Passing to a C-style API that requires T*: use .get()
void legacy_render(Widget* w, int flags);
for (const auto& up : widgets) {
    legacy_render(up.get(), DEFAULT_FLAGS);   // .get() — raw pointer, no ownership transfer
}

// Collecting non-owning views for temporary use (raw pointers are fine here)
std::vector<Widget*> views;
for (const auto& up : widgets) {
    views.push_back(up.get());    // observing, not owning — correct
}
// NB: views must not outlive widgets

C API boundaries

C libraries do not know about smart pointers. Any time you call a C API, you must provide a raw pointer. The correct pattern is to manage the object with a smart pointer in your C++ code, then extract the raw pointer at the boundary via .get(). The smart pointer continues to own the object; the C function receives only a temporary non-owning view. Never pass ownership to a C API by releasing the smart pointer — if the API calls free() or any cleanup that isn't delete, the result is undefined behaviour.

// C API: takes a raw pointer, does not take ownership
extern "C" void process_buffer(uint8_t* buf, size_t len);

// Correct: smart pointer manages lifetime; raw pointer crosses the C boundary
auto buffer = std::make_unique<uint8_t[]>(1024);
process_buffer(buffer.get(), 1024);
// buffer is still alive and owned; C function saw only a non-owning raw pointer

// Correct: shared_ptr with custom deleter for C-managed objects
auto db = std::shared_ptr<sqlite3>(open_db("app.db"),
    [](sqlite3* d) { sqlite3_close(d); });
// Pass the raw handle to SQLite APIs, keep the shared_ptr to manage lifetime
sqlite3_exec(db.get(), "SELECT 1", nullptr, nullptr, nullptr);

The performance cost of shared_ptr

unique_ptr has zero overhead over a raw pointer in the vast majority of code: it compiles down to exactly the same operations, and the destructor call is resolved at compile time. shared_ptr is different. Its reference count must be thread-safe, which means every copy and every destruction of a shared_ptr performs an atomic increment or decrement. Acquiring the mutex for the atomic operation has a real cost — it is cheap in absolute terms, but it is non-zero, and it adds up in tight loops.

As Davidson and Gregory explain in Beautiful C++, this is not something to worry about until profiling surfaces it as a hotspot. The safety that smart pointers provide is worth a great deal of engineering effort. But if you discover that shared_ptr copies are registering in your profile, the usual cause is that a function is taking shared_ptr<T> by value when it only needs to look at the object. The fix is to change the parameter to const T& or T*, not to abandon smart pointers entirely.

// Expensive: copies shared_ptr → atomic increment on call, atomic decrement on return
size_t measure(std::shared_ptr<Widget> w) { return w->size(); }

// Free: takes a reference → no atomic operations, no ownership involved
size_t measure(const Widget& w) { return w.size(); }

// At the call site: dereference the shared_ptr once, pass a reference
auto sp = std::make_shared<Widget>();
measure(*sp);  // no reference count change — just a reference to the managed object

Prefer unique_ptr over shared_ptr by default

Shared ownership is not the same as unclear ownership, and it is rarer than it might first appear. Before reaching for shared_ptr, ask whether there is truly a need for multiple owners to decide simultaneously when the object is destroyed, or whether one clear owner can hold a unique_ptrwhile everyone else observes via references and raw pointers.

As the authors of Beautiful C++ put it: your default choice for dynamic storage should be unique_ptr. Use shared_ptronly where reasoning about lifetime is genuinely impossible with a single owner — and even then, treat it as a sign that the design might benefit from a clearer ownership structure. Shared ownership that arises from not finding the right abstraction tends to create subtle bugs and cycles.

// Scenario: multiple UI columns displaying the same tweet
// Wrong instinct: shared_ptr because "multiple things reference the tweet"
std::shared_ptr<Tweet> tweet = loadTweet(42);
column1.display(tweet);   // shared_ptr copy: column1 now shares ownership
column2.display(tweet);   // shared_ptr copy: column2 now shares ownership
// Who decides when the tweet is destroyed? All of them, independently — unclear

// Better design: one owner holds the tweet; columns observe it
std::unique_ptr<Tweet> tweet = loadTweet(42);
Tweet& ref = *tweet;          // non-owning reference
column1.display(ref);         // column1 observes, does not own
column2.display(ref);         // column2 observes, does not own
// Destruction: tweet's owner decides — clear single point of control

gsl::owner — annotating unavoidable raw ownership

Sometimes you inherit a codebase where changing raw pointers to smart pointers would break the ABI or require massive refactoring. The Guidelines Support Library (GSL) provides gsl::owner<T*> as a documentation type: it is literally a type alias for T* with no runtime overhead, but it signals in the type system that the holder is responsible for deleting the object. Static analysis tools and code reviewers can then treat it differently from an unadorned raw pointer. Use it as a last resort when smart pointers genuinely cannot be adopted.

#include <gsl/gsl>

// Without gsl::owner: is this function returning an owning pointer or not?
int* produce() {
    return new int(42);  // ambiguous: does the caller own this?
}

// With gsl::owner: intent is documented in the type
gsl::owner<int*> produce() {
    return new int(42);  // caller is explicitly the new owner
}

void consume(gsl::owner<int*> p) {
    delete p;   // documented as the owner — deletion is expected
}

// In new code: prefer unique_ptr instead.
// gsl::owner is for legacy codebases where ABI constraints prevent smart pointers.

Decision guide: which pointer type?

You created the object and will be the sole manager of its lifetime

unique_ptr<T> — clear exclusive ownership, zero overhead

Multiple independent parties must each keep the object alive

shared_ptr<T> — shared ownership with automatic destruction

You want to observe a shared_ptr object without keeping it alive

weak_ptr<T> — non-owning handle that detects destruction

Function parameter: uses the object but doesn't store or share it, null not valid

const T& or T& — preferred. No ownership, no null risk

Function parameter: uses the object but doesn't store it, null is a meaningful input

T* — non-owning, nullable. Clearly documented as non-owning

Child node or sibling that refers back to a parent/owner

T* — non-owning back-pointer. Parent's lifetime guarantees validity

C API boundary: API requires a raw pointer, you own the object

Pass .get() from your smart pointer. Never release ownership to C code

Legacy codebase, ABI constraints, can't use smart pointers for ownership

gsl::owner<T*> to annotate intent; plan migration to smart pointers

Sign in to track progress