Stack versus heap, new and delete, memory leaks, RAII, and the smart pointers that make manual delete unnecessary: unique_ptr, shared_ptr and weak_ptr.
Explain where a variable lives (stack or heap) and when it is destroyed
Pair every new with delete and every new[] with delete[], and say why modern code rarely writes either
Find leaks, use-after-free and double delete with AddressSanitizer and the macOS leaks tool
Use RAII so cleanup happens automatically, and predict constructor and destructor order
Pick unique_ptr, shared_ptr or weak_ptr for a given ownership, and follow the rule of zero
01
The stack and the heap
✓
A running C++ program keeps its data in two main places. The stack holds local variables and function parameters: each call pushes a frame, and when the function returns the whole frame is thrown away in one step. It is very fast and completely automatic, but it is small (typically 8 MB on macOS and Linux for the main thread) and everything on it dies when its scope ends. The heap (the "free store") is a large pool you ask for memory from at runtime. Heap memory lives until someone releases it, which is both its power and its danger.
Stack
Heap
How you get it
declare a local: int x;, std::string s;
new, or (better) a container or smart pointer that calls it for you
When it is freed
automatically, at the closing } of the scope
when delete runs, or when the owning object is destroyed
Size
small, fixed at compile time per variable
large, chosen at runtime
Speed
essentially free
an allocator call each time
Typical bugs
returning a pointer to a local (dangling)
leaks, use-after-free, double delete
You have already been using the heap without seeing it. A std::vector<int> variable is a small object on the stack (three pointers), and its elements live on the heap. When the vector goes out of scope, its destructor frees them. That pattern, a stack object that owns heap memory and frees it automatically, is the core idea of this module.
C++main.cpp
1234567891011121314151617
#include<iostream>#include<vector>intmain(){int count =3;// on the stack
std::vector<int>data(1'000'000,7);// handle on the stack, a million ints on the heap
std::cout <<"sizeof(count) = "<<sizeof(count)<<'\n';
std::cout <<"sizeof(data) = "<<sizeof(data)<<" (just the handle)\n";
std::cout <<"elements = "<< data.size()<<'\n';{
std::vector<int>temp(10,1);// inner scope
std::cout <<"temp sum = "<< temp[0]*10<<'\n';}// temp and its heap block are freed here
std::cout <<"count still "<< count <<'\n';}
Outputcompiled & run with real C++
sizeof(count) = 4
sizeof(data) = 24 (just the handle)
elements = 1000000
temp sum = 10
count still 3
sizeof measures only the object itself. The vector's four megabytes of elements are on the heap and do not count.
Stack overflow
Declaring a huge local array such as int grid[10'000'000]; (40 MB) overflows the stack and crashes before the first line of main runs. Large or runtime-sized data belongs in a std::vector, which puts it on the heap. Deep recursion overflows the stack the same way.
02
new and delete, new[] and delete[]
✓
new T(args) allocates one object on the heap, constructs it, and returns a pointer to it. delete p destroys it and returns the memory. For arrays, new T[n] must be paired with delete[] p. The rules are strict: every new gets exactly one matching delete, after the last use and never twice. Breaking them causes the three classic bugs:
Leak: forgetting delete. The memory is lost until the process exits.
Use-after-free: using the pointer after delete. The memory may already hold something else.
Double delete: deleting the same memory twice, often through two pointers to one object. The allocator's bookkeeping gets corrupted.
C++main.cpp
123456789101112131415161718
#include<iostream>#include<string>intmain(){int* answer =newint(42);// one int on the heap
std::string* name =new std::string("Asha");// one string on the heap
std::cout <<*answer <<' '<<*name <<" ("<< name->size()<<" chars)\n";delete answer;// one new, one deletedelete name;int n =5;// size known only at runtimedouble* prices =newdouble[n]{9.5,4.0,12.25,3.0,7.5};double total =0;for(int i =0; i < n;++i) total += prices[i];
std::cout <<"total "<< total <<'\n';delete[] prices;// new[] pairs with delete[]
prices =nullptr;// no dangling address left behind}
Outputcompiled & run with real C++
42 Asha (4 chars)
total 36.25
Your turn
Rewrite the array part with std::vector<double> prices{...}. Notice that the delete[] and the nullptr line both disappear: nothing is left to forget.
Error you will hit
'delete' applied to a pointer that was allocated with 'new[]'
main.cpp:6:5: warning: 'delete' applied to a pointer that was allocated with 'new[]'; did you mean 'delete[]'? [-Wmismatched-new-delete]
6 | delete readings;
| ^
| []
main.cpp:4:21: note: allocated with 'new[]' here
4 | int* readings = new int[4]{3, 1, 4, 1};
| ^
Why the compiler said that
delete destroys one object; delete[] destroys every element of an array, using a hidden element count that new[] stored. Mixing them is undefined behaviour: with int it often seems to work, with class types it skips destructors or corrupts the heap. Clang catches this case with a default-on warning (no flag needed) only because the new[] is visible on line 4; once the pointer has passed through a function, no compiler can tell.
The fix
Use delete[], or better, do not own arrays through raw pointers at all: std::vector or std::make_unique<int[]>(n) pick the right form for you.
$ c++ -std=c++20 -fsanitize=address -g main.cpp && ./a.out
==91441==ERROR: AddressSanitizer: heap-use-after-free on address 0x6020000000f0 at pc 0x000104ac475c bp 0x00016b33a620 sp 0x00016b33a618
READ of size 4 at 0x6020000000f0 thread T0
#0 0x000104ac4758 in main main.cpp:6
freed by thread T0 here:
#1 0x000104ac4704 in main main.cpp:5
previously allocated by thread T0 here:
#1 0x000104ac468c in main main.cpp:4
SUMMARY: AddressSanitizer: heap-use-after-free main.cpp:6 in main
Why the compiler said that
After line 5 the memory belongs to the allocator again, but balance still holds its address. This run used AddressSanitizer (-fsanitize=address -g), which reports all three facts you need: where the bad read happened (line 6), where the memory was freed (line 5) and where it was allocated (line 4). A normal build usually just prints 500, or some other number, and moves on.
The fix
Do not use a pointer after deleting it. In practice: do not call delete yourself. Let a std::unique_ptr own the object and free it when the owner goes out of scope.
C++
12
auto balance = std::make_unique<int>(500);// #include <memory>
std::cout <<*balance <<'\n';// freed automatically at the end of the scope
Error you will hit
AddressSanitizer: attempting double-free
C++
123456
intmain(){int* p =newint(7);int* alias = p;delete p;delete alias;}
$ c++ -std=c++20 -fsanitize=address -g main.cpp && ./a.out
==91451==ERROR: AddressSanitizer: attempting double-free on 0x6020000000f0 in thread T0:
#1 0x000104a38688 in main main.cpp:5
freed by thread T0 here:
#1 0x000104a3866c in main main.cpp:4
previously allocated by thread T0 here:
#1 0x000104a385ec in main main.cpp:2
SUMMARY: AddressSanitizer: double-free main.cpp:5 in main
Why the compiler said that
p and alias are two pointers to one object. Deleting through both frees it twice. The question the code never answered is: which pointer owns the object? ASan (-fsanitize=address -g) catches it immediately; without it, a double free may abort, or silently corrupt the heap and crash somewhere unrelated much later.
The fix
Exactly one owner deletes. Make the owner a std::unique_ptr and give everyone else a plain non-owning pointer or reference, which never deletes.
03
Memory leaks and how to find them
✓
A leak is heap memory that is still allocated but that no pointer refers to any more, so nothing can ever free it. One leaked block is harmless; a leak inside a loop, or in a server that runs for weeks, grows until the process is killed. Leaks are not undefined behaviour, and nothing crashes, which is exactly why they need a tool to find.
The common causes: a missing delete, an early return that skips the delete, an exception thrown between new and delete, or overwriting the only pointer to a block. The last three are why "just remember to delete" does not scale.
Error you will hit
leaks: 1 leak for 48 total leaked bytes
C++
1234567891011
#include<iostream>voidprocess(int n){int* buffer =newint[n];
buffer[0]= n;
std::cout <<"processed "<< buffer[0]<<'\n';}// buffer goes out of scope; the 40 bytes are never freedintmain(){process(10);}
$ c++ -std=c++20 -g main.cpp -o main && leaks --atExit -- ./main
processed 10
...
Process 96483: 1 leak for 48 total leaked bytes.
STACK OF 1 INSTANCE OF 'ROOT LEAK: <malloc in process(int)>':
4 main 0x100bbc5e4 main + 16 main.cpp:11
3 main 0x100bbc514 process(int) + 44 main.cpp:4
2 libc++abi.dylib 0x18738a7e8 operator new(unsigned long) + 52
Why the compiler said that
The pointer buffer is a local, so it disappears at the end of process, but the 40 bytes it pointed at stay allocated (the allocator rounds the block up to 48). This report comes from the leaks tool that ships with Xcode on macOS, run with --atExit so it checks when the program ends; it names the line that allocated the leaked block. On Linux, AddressSanitizer includes LeakSanitizer and prints a similar "detected memory leaks" report automatically; on Apple Silicon Macs LeakSanitizer is not supported (detect_leaks is not supported on this platform), which is why this card uses leaks.
The fix
Tie the memory to an object whose destructor frees it. For a buffer, that object is a std::vector. There is no delete to forget, skip, or jump over with an exception.
C++
12345678
#include<iostream>#include<vector>voidprocess(int n){
std::vector<int>buffer(n);
buffer[0]= n;
std::cout <<"processed "<< buffer[0]<<'\n';}// vector's destructor frees the memory here, on every path out
bash
12345678
# macOS (Xcode command-line tools): check for leaks when the program exits
leaks --atExit -- ./main
# Linux: AddressSanitizer includes LeakSanitizer and reports leaks at exit
c++ -std=c++20 -fsanitize=address -g main.cpp -o main && ./main
# Linux alternative, no rebuild needed (slower)
valgrind --leak-check=full ./main
Pick the one for your platform. Run it on your test suite, not only by hand.
C++main.cpp
1234567891011121314151617181920212223
#include<iostream>#include<stdexcept>#include<vector>// With a raw new[] here, the throw below would leak the buffer.// With a vector, the buffer is freed while the exception passes through.intparseAll(int count,bool failHalfway){
std::vector<int>buffer(count);for(int i =0; i < count;++i){if(failHalfway && i == count /2)throw std::runtime_error("bad record");
buffer[i]= i * i;}return buffer[count -1];}intmain(){
std::cout <<parseAll(5,false)<<'\n';try{parseAll(5,true);}catch(const std::exception& e){
std::cout <<"caught: "<< e.what()<<" (and nothing leaked)\n";}}
Outputcompiled & run with real C++
16
caught: bad record (and nothing leaked)
04
RAII: cleanup that cannot be forgotten
✓
RAII ("Resource Acquisition Is Initialisation") is the most important idea in C++. It says: wrap every resource (heap memory, a file, a lock, a socket) in an object that acquires it in its constructor and releases it in its destructor. C++ guarantees that a local object's destructor runs when its scope ends, whether by reaching the }, by return, by break, or by an exception passing through. So the release cannot be skipped.
Destructors run in the reverse order of construction: the last object built is the first destroyed. That matters when one resource depends on another, like a transaction that must end before the database connection closes.
normal:
acquire db
acquire tx
acquire log
release log
release tx
release db
early return:
acquire db
acquire tx
release tx
release db
Your turn
Add a throw std::runtime_error("x") after Tracer tx and call work inside a try. The two release lines still appear, before the catch block runs.
VisualizeConstruction and destruction orderStep 1 / 7
1int work(bool leaveEarly){
2 Tracer db("db");
3 Tracer tx("tx");
4if(leaveEarly)return1;
5 Tracer log("log");
6return0;
7}
Line 2
db is constructed first.
Variables now
alive
db
Printed so far
acquire db
All 7 steps as a table
Step
Line
What happened
Variables now
1
2
db is constructed first.
alive = db
2
3
tx is constructed second.
alive = db, tx
3
4
leaveEarly is false, so execution continues.
alive = db, tx
4
5
log is constructed last.
alive = db, tx, log
5
6
return 0 leaves the scope. Destructors run in reverse: log first.
alive = db, tx
6
6
Then tx.
alive = db
7
6
Then db, the first one built, is the last one released.
alive = (none)
You already use RAII everywhere
std::string, std::vector, std::ifstream (closes the file), std::lock_guard (unlocks the mutex) and every smart pointer below are RAII types. Good C++ code has almost no delete, close() or unlock() calls in it, because destructors do that work.
05
std::unique_ptr: one owner
✓
std::unique_ptr<T> (from <memory>) is RAII for one heap object. It holds a pointer and deletes it in its destructor. Create one with std::make_unique<T>(args), use it like a pointer (*p, p->x), and never call delete. It costs nothing over a raw pointer: same size, no reference count.
As its name says, there is exactly one owner. A unique_ptrcannot be copied, because two copies would both delete the object. It can be moved with std::move, which transfers ownership and leaves the source empty (null). Passing a unique_ptr by value to a function says "this function takes ownership".
editing draft.txt
archiving draft.txt
closing draft.txt
draft is empty: true
open files: 2
closing b.txt
open files: 1
closing a.txt
Your turn
Add a function void preview(const Document& d) and call it as preview(*open[0]). A function that only uses an object takes a reference, not a smart pointer.
Error you will hit
call to implicitly-deleted copy constructor of 'std::unique_ptr'
main.cpp:6:34: error: call to implicitly-deleted copy constructor of 'std::unique_ptr<std::string>' (aka 'unique_ptr<basic_string<char>>')
6 | std::unique_ptr<std::string> copy = report;
| ^ ~~~~~~
unique_ptr.h:209:55: note: copy constructor is implicitly deleted because 'unique_ptr<std::string>' has a user-declared move constructor
209 | _LIBCPP_HIDE_FROM_ABI _LIBCPP_CONSTEXPR_SINCE_CXX23 unique_ptr(unique_ptr&& __u) _NOEXCEPT
| ^
Why the compiler said that
Copying would create two owners of one string, and both would delete it: a double free. unique_ptr deletes its copy constructor on purpose, so the mistake is a compile error instead of a crash. Exactly the same error appears when you pass a unique_ptr by value without std::move: archive(draft).
The fix
Decide what you meant. To hand over ownership, std::move it (the source becomes null). To let someone look at the object, pass a reference (*report) or a raw pointer (report.get()). To really have two independent strings, copy the string: std::make_unique<std::string>(*report).
C++
12
auto report = std::make_unique<std::string>("Q3 numbers");
std::unique_ptr<std::string> owner = std::move(report);// report is now nullptr
06
std::shared_ptr: shared ownership
✓
Sometimes several parts of a program need to keep the same object alive, and none of them knows which will finish last: a cached image used by several open windows, a config object shared by worker objects. std::shared_ptr<T> handles this with a reference count. Every copy increments the count, every destroyed copy decrements it, and the object is deleted when the count reaches zero. use_count() shows the current count.
#include<iostream>#include<memory>#include<string>#include<vector>structConfig{
std::string region;explicitConfig(std::string r):region(std::move(r)){}~Config(){ std::cout <<"config freed\n";}};structWorker{int id;
std::shared_ptr<Config> config;// each worker co-owns the config};intmain(){auto cfg = std::make_shared<Config>("ap-south-1");
std::cout <<"count "<< cfg.use_count()<<'\n';
std::vector<Worker> workers;
workers.push_back({1, cfg});
workers.push_back({2, cfg});
std::cout <<"count "<< cfg.use_count()<<'\n';
cfg.reset();// main lets go of its copy
std::cout <<"worker 2 still sees "<< workers[1].config->region <<'\n';
std::cout <<"count "<< workers[0].config.use_count()<<'\n';
workers.clear();// last owners gone: object deleted now
std::cout <<"end of main\n";}
Outputcompiled & run with real C++
count 1
count 3
worker 2 still sees ap-south-1
count 2
config freed
end of main
Use std::make_shared: it allocates the object and the reference count in one block.
A shared_ptr is twice the size of a raw pointer, and copying it updates the count atomically (thread-safe), which costs something. Pass it by const& or pass a plain reference to the object when a function does not need to share ownership.
Default to unique_ptr. Reach for shared_ptr only when ownership is genuinely shared. A unique_ptr converts to a shared_ptr later if needed; the reverse is not possible.
07
std::weak_ptr and reference cycles
✓
Reference counting has one blind spot: a cycle. If A holds a shared_ptr to B and B holds one to A, each keeps the other's count above zero forever. When the rest of the program lets go, neither is ever deleted.
Error you will hit
leaks: ROOT CYCLE of two shared_ptr objects
C++
123456789101112131415161718
#include<iostream>#include<memory>#include<string>structPerson{
std::string name;
std::shared_ptr<Person> partner;// strong in both directionsexplicitPerson(std::string n):name(std::move(n)){}~Person(){ std::cout <<"bye "<< name <<'\n';}};intmain(){auto a = std::make_shared<Person>("Asha");auto b = std::make_shared<Person>("Ravi");
a->partner = b;
b->partner = a;
std::cout <<"use_count: "<< a.use_count()<<'\n';}// a and b go away, but each Person still owns the other
$ c++ -std=c++20 -g main.cpp -o main && leaks --atExit -- ./main
use_count: 2
...
Process 92707: 2 leaks for 160 total leaked bytes.
...
2 (160 bytes) ROOT CYCLE: <std::__shared_ptr_emplace<Person> 0xb46c24000> [80]
1 (80 bytes) ROOT CYCLE: <std::__shared_ptr_emplace<Person> 0xb46c24050> [80]
CYCLE BACK TO <std::__shared_ptr_emplace<Person> 0xb46c24000> [80]
Why the compiler said that
The program compiles cleanly and exits normally, but neither "bye" line is printed: no destructor ran. When a and b are destroyed, each count drops from 2 to 1, not to 0, because each Person still owns the other. The macOS leaks tool (run with --atExit) even labels it a ROOT CYCLE.
The fix
Make one direction non-owning with std::weak_ptr, shown next.
A std::weak_ptr refers to an object owned by shared_ptrs without adding to the count. Because the object may already be gone, you cannot use a weak_ptr directly: call lock(), which returns a shared_ptr that is either valid (and keeps the object alive while you use it) or empty. The usual design: parents own children with shared_ptr (or unique_ptr), and children point back with weak_ptr.
#include<iostream>#include<memory>#include<string>structPerson{
std::string name;
std::weak_ptr<Person> partner;// does not ownexplicitPerson(std::string n):name(std::move(n)){}~Person(){ std::cout <<"bye "<< name <<'\n';}};voidgreetPartner(constPerson& p){if(auto other = p.partner.lock()){// shared_ptr, or empty if gone
std::cout << p.name <<" says hi to "<< other->name <<'\n';}else{
std::cout << p.name <<" has no partner any more\n";}}intmain(){auto a = std::make_shared<Person>("Asha");{auto b = std::make_shared<Person>("Ravi");
a->partner = b;
b->partner = a;
std::cout <<"use_count: "<< a.use_count()<<'\n';// weak refs do not countgreetPartner(*a);}// b's count hits 0: Ravi is deletedgreetPartner(*a);
std::cout << std::boolalpha <<"expired: "<< a->partner.expired()<<'\n';}
Outputcompiled & run with real C++
use_count: 1
Asha says hi to Ravi
bye Ravi
Asha has no partner any more
expired: true
bye Asha
08
Custom deleters and the rule of zero
✓
Smart pointers call delete by default, but you can give them any cleanup function, a custom deleter. That turns them into RAII wrappers for resources that come from C libraries, where you get a handle from one function and must release it with another (fopen/fclose, sqlite3_open/sqlite3_close). For unique_ptr the deleter type is part of the smart pointer's type.
C++main.cpp
12345678910111213141516171819202122232425262728
#include<iostream>#include<memory>// Pretend C library: you must call closeConnection on everything openConnection returns.structConnection{int id;};Connection*openConnection(int id){
std::cout <<"open #"<< id <<'\n';returnnewConnection{id};}voidcloseConnection(Connection* c){
std::cout <<"close #"<< c->id <<'\n';delete c;}// A deleter type that calls the library's cleanup function.structConnectionCloser{voidoperator()(Connection* c)const{closeConnection(c);}};usingConnectionPtr= std::unique_ptr<Connection,ConnectionCloser>;intmain(){ConnectionPtrprimary(openConnection(1));{ConnectionPtrreplica(openConnection(2));
std::cout <<"querying #"<< replica->id <<'\n';}// closeConnection(replica) runs here
std::cout <<"still using #"<< primary->id <<'\n';}// and closeConnection(primary) here
Outputcompiled & run with real C++
open #1
open #2
querying #2
close #2
still using #1
close #1
The rule of zero
If every member of your class manages itself (a std::string, a std::vector, a unique_ptr), the class needs no destructor, no copy constructor, no copy assignment and no move operations of its own: the compiler-generated ones do the right thing member by member. This is the rule of zero, and it is the goal for almost every class you write. You only write those special functions for a class whose one job is to manage a raw resource, and Module 07 shows what that takes (the rule of three and five).
#include<iostream>#include<memory>#include<string>#include<vector>// Rule of zero: no destructor, no copy/move functions written by hand.classPlaylist{public:explicitPlaylist(std::string name):name_(std::move(name)){}voidadd(std::string song){ songs_.push_back(std::move(song));}
std::size_tsize()const{return songs_.size();}const std::string&name()const{return name_;}private:
std::string name_;// manages its own memory
std::vector<std::string> songs_;// manages its own memory};intmain(){Playlistroad("road trip");
road.add("song A");
road.add("song B");Playlist copy = road;// compiler-generated copy: a deep copy
copy.add("song C");
std::cout << road.name()<<": "<< road.size()<<'\n';
std::cout << copy.name()<<": "<< copy.size()<<'\n';auto owned = std::make_unique<Playlist>(road);// heap copy, freed automatically
std::cout <<"owned: "<< owned->size()<<'\n';}
Outputcompiled & run with real C++
road trip: 2
road trip: 3
owned: 2
Raw new and delete appear nowhere in this table.
Situation
Use
A local object or a member
a plain value: Widget w; (no pointer at all)
Many objects of one type
std::vector<T>
One heap object, one owner (polymorphism, optional member, big object)
std::unique_ptr<T>
One heap object, owners with unpredictable lifetimes
std::shared_ptr<T>
A back-pointer or cache entry that must not keep the object alive
std::weak_ptr<T>
Using an object someone else owns
T& / const T&, or T* if it can be null
A handle from a C library
std::unique_ptr<T, Deleter>
Stack
Memory for local variables and call frames. Freed automatically when a scope ends.
Heap (free store)
Memory requested at runtime with new (usually via a container or smart pointer). Lives until released.
Memory leak
Heap memory that is still allocated but no longer reachable, so it can never be freed.
Use-after-free
Accessing memory through a pointer after it has been deleted. Undefined behaviour.
Double delete
Deleting the same memory twice, usually through two pointers. Undefined behaviour.
RAII
Resource Acquisition Is Initialisation: an object acquires a resource in its constructor and releases it in its destructor.
std::unique_ptr
A move-only smart pointer with exactly one owner. Deletes the object when destroyed.
std::shared_ptr
A reference-counted smart pointer. The object is deleted when the last shared_ptr to it goes away.
std::weak_ptr
A non-owning observer of a shared_ptr-managed object. lock() gives temporary access if it still exists.
Custom deleter
A function object a smart pointer calls instead of delete, for resources like C library handles.
Rule of zero
Let members manage resources so the class needs no hand-written destructor, copy or move functions.
Quick check
A Tree owns its Nodes through shared_ptr. Each Node needs to reach its parent. What should the parent pointer be?
A shared_ptr back to the parent creates a cycle (parent owns child, child owns parent) and nothing is ever freed. A unique_ptr would claim the child owns its parent. A reference cannot be reseated or be empty for the root. weak_ptr reaches the parent without owning it.
Quick check
Which line compiles, given auto p = std::make_unique<int>(5);?
A unique_ptr cannot be copied, which rules out the first two, and converting it to a shared_ptr also requires moving: std::shared_ptr<int> q = std::move(p);. Only the explicit move compiles; afterwards p is null.
Rarely. Use a plain value when you can, std::vector for many objects, and std::make_unique or std::make_shared when you need a single heap object. Raw new/delete belongs inside the few low-level classes whose job is managing memory.
What is the difference between unique_ptr and shared_ptr?
unique_ptr has exactly one owner, cannot be copied (only moved), and costs nothing over a raw pointer. shared_ptr allows many owners and deletes the object when the last one goes away, at the cost of a reference count. Default to unique_ptr.
How do I detect memory leaks in C++ on a Mac?
Run the program under the leaks tool that ships with Xcode: leaks --atExit -- ./main. It lists each leaked block and the line that allocated it. AddressSanitizer still catches use-after-free and double free on macOS, but its leak checker (LeakSanitizer) is not supported on Apple Silicon; on Linux, -fsanitize=address reports leaks automatically.
Finish the C++ handbook, then get hired
Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.