How I hunted memory and GDI leaks in Notepad++
I wanted to know what a leak profiler would turn up in a large Windows application I had not written a line of, and Notepad++ makes an unusually fair test. It is written in C++ against the Win32 API, it has been shipping since 2003, people leave it open all day, and because the source is public you can check every claim below rather than taking my word for it. So I built version 8.9.7 with debug symbols, ran it under Deleaker, used it the way you would use any editor, and closed it.
A word on the tool. Deleaker, our leak detector for native Windows applications, runs standalone or inside Visual Studio, RAD Studio and Qt Creator, and it tracks the heap along with GDI, USER and kernel handles.
What came back looked reassuring. Starting the program and closing it again left 199,112 bytes that were never freed, which sounds like a lot until you work out where it goes: two ownership bugs whose cost is fixed by how many files you happened to have open. Real bugs, both worth fixing, and neither able to run away from you no matter how long the session lasts.
Then I changed one setting in the profiler, and a leak appeared that weighs nothing at all. A missing ReleaseDC abandons a screen device context and a bitmap for every color icon Notepad++ puts on a menu, and one of the three code paths that builds those icons runs every single time you right-click a document tab. Twenty right-clicks cost 100 device contexts and 100 bitmaps, and they are never given back. A device context has no size in bytes, so nothing that counts bytes was ever going to show it to me.
That one had been leaking since 2020. What follows is how all of it turned up, in the order it turned up, including the part near the end where I went to report what I had found and discovered that two of the three had been sitting in the issue tracker for months, with a fix that never landed.
Getting symbols and a clean slate
notepadPlus.sln, Debug, x64. Every stack in this article resolves to a file and a line, which is the only thing the build had to deliver.
The configuration is the part worth a minute. Notepad++ wants its data files beside the binary, so copy the exe and the PDB in among them and add an empty doLocalConf.xml, which moves configuration out of %APPDATA% and into that folder. The run then starts from a state I chose rather than one my machine had accumulated, and that turns out to decide whether one of the leaks below appears at all.
The stacks arrive through _malloc_dbg and heap_alloc_dbg, as they do in any Debug build. Deleaker resolves past them to the call site.
Taking the snapshot
Deleaker launches the binary itself. Point Command at your notepad++.exe, press Start Debugging, and use the editor.
There are two ways to end up with something to read, and they answer different questions. Close the application and Deleaker captures a post mortem snapshot: everything still allocated when the process exited, and therefore everything that was never freed. Or take a snapshot mid run as a baseline, do some specific piece of work, and compare a later snapshot against it; the difference is what that work left behind. A post mortem list mixes a global allocated once at startup, which costs a fixed few bytes forever, with a leak that repeats on every use, which is the one that hurts. The comparison separates them for you. Both turned out to be necessary here, and it was the comparison that settled the important question.
Before reading either, decide whose leak you are looking at. This snapshot has 1006 entries under <All Leaks>, and a good many belong to Windows rather than to Notepad++: rows attributed to ntdll.dll, gdi32full.dll, UxTheme.dll and CoreMessaging.dll, with <Unknown> in the source column because there is no source to point at. Narrowed to Notepad++'s own code, which is what the exported snapshot behind this article contains, there are 212 distinct call stacks, 326 blocks and 199,112 bytes. Everything from here on is that.
Now sort by Hit Count, the number of times one call stack allocated.

The rows at the top of the list are all the same shape: 31 hits, 34,348 bytes. The stack under the selected one runs from the CRT allocator through operator new into Lexilla::LexerBase::LexerBase and out through CreateLexer to ScintillaEditView::setLexerFromLangID and Notepad_plus::init. The constructor explains the 31 by itself:
LexerBase::LexerBase(const LexicalClass *lexClasses_, size_t nClasses_) :
lexClasses(lexClasses_), nClasses(nClasses_) {
for (int wl = 0; wl < numWordLists; wl++)
keyWordLists[wl] = new WordList;
keyWordLists[numWordLists] = nullptr;
}numWordLists is KEYWORDSET_MAX + 1, which is 31. So the hit count here is a loop bound, not a code path that ran 31 times, and the 34,348 bytes are the total for the group rather than the size of one block. 34,348 divided by 31 is 1,108 bytes per WordList, which is about what one costs.
So a high count is a good place to look and a bad thing to conclude from. These word lists are not a leak in their own right either, they are the contents of one: free the lexer that owns them and all 31 go with it. Which turns the question into who was supposed to free the lexer, and that turns out to be the same question as almost everything else on the list.
The document that outlives its window
Four lexers leaked, and each dragged its 31 word lists along, which is 140,816 of the 199,112 bytes. Another 25,216 bytes are the machinery a Scintilla document owns: CellBuffer, UndoHistory, ChangeHistory, LineLevels, LineMarkers, LineAnnotation, decorations, a character category map. The allocations a document makes exactly once, among them EditModel::EditModel, both CellBuffer constructors, DecorationListCreate and the character category map, each appear four times over in the list. Four is not a coincidence: Notepad_plus holds exactly four Scintilla views, the two you can see plus an invisible one used for searching and one used by the file manager.
The lexer is not owned by the window, though. ScintillaBase installs it into the document, at ScintillaBase.cxx line 645, via pdoc->SetLexInterface(...). So a document that survives keeps its lexer, and its lexer keeps 31 word lists. Five sixths of the 199 KB reduces to a single question: why did four documents survive?
Not because the windows lived. They are destroyed properly. WM_DESTROY on the main window calls killAllChildren, which tears down all four views explicitly:
_mainEditView.destroy();
_subEditView.destroy();
_invisibleEditView.destroy();
_fileEditView.destroy();and each of those is a DestroyWindow, after which ScintillaWin deletes itself:
if (iMessage == WM_NCDESTROY) {
try {
sci->Finalise();
delete sci;
} catch (...) {Deleting the ScintillaWin destroys its Editor and its EditModel, and the EditModel releases the document. The reason that is not enough is that a Scintilla document is reference counted, and Notepad++ took a second reference on it back at startup, in attachDefaultDoc:
BufferID ScintillaEditView::attachDefaultDoc()
{
// get the doc pointer attached (by default) on the view Scintilla
Document doc = execute(SCI_GETDOCPOINTER, 0, 0);
execute(SCI_ADDREFDOCUMENT, 0, doc);
BufferID id = MainFileManager.bufferFromDocument(doc, _isMainEditZone);
Buffer * buf = MainFileManager.getBufferByID(id);The document each view created for itself is handed to FileManager, with a reference taken to say so. When the window dies the count goes from two to one, and the document stays. Giving that reference back is FileManager's job, and here is its destructor, in full:
FileManager::~FileManager()
{
for (std::vector<Buffer *>::iterator it = _buffers.begin(), end = _buffers.end(); it != end; ++it)
{
delete *it;
}
}The Buffer objects are deleted. The documents they hold are not released, and Buffer has no destructor that would do it. That single missing call is 166,032 of the 199,112 bytes.
It is worth being precise about why this does not grow while the application is running, because the ordinary close path gets it right. FileManager::closeBuffer releases the document as soon as the last reference to a buffer goes away:
if (!refs) // buffer can be deallocated
{
_pscratchTilla->execute(SCI_RELEASEDOCUMENT, 0, buf->_doc); //release for FileManager, Document is now gone
_buffers.erase(_buffers.begin() + index);
delete buf;
_nbBufs--;
}So closing a tab is clean, and only the buffers still open at exit are stranded. Open thirty files and the bill scales with them, once, at shutdown.
The snapshot supports that reading in a way worth pausing on. The Buffer objects themselves do not appear anywhere in the leak list, so the destructor above demonstrably ran, and did exactly the half of its job that the code shows. The tool did not just find something unfreed, it drew the shape of the bug.
There is a wrinkle that makes this more than a missing line, and it is why I put this finding last in the fix list at the end. By the time ~FileManager runs, it is too late to fix it there. FileManager is a function local static, so its destructor fires during CRT teardown, after wWinMain has returned and long after killAllChildren destroyed the very Scintilla window that _pscratchTilla points at. The release has to happen while a live Scintilla still exists, which makes this a question about where buffer teardown belongs rather than about one forgotten statement.
The 33 KB that a default installation leaks
Re-sort the same list by Size and a different bug comes to the top. The two 34,348 rows above it are the word list groups from the previous section, thirty one blocks apiece; the 32,820 below them is one allocation, which makes it the largest single block in the process. With the 260 byte document object that owns it, 33,080 bytes, and its stack names the culprit end to end.

Through pugixml's allocator, into NppXml::createNewDeclaration, into NppParameters::writeDefaultUDL, called from writeNeed2SaveUDL, called from Notepad_plus::saveUserDefineLangs while Notepad++ is saving its settings on the way out:
for (const auto& udl : _pXmlUserLangsDoc)
{
if (!_pXmlUserLangDoc._doc)
{
_pXmlUserLangDoc._doc = new NppXml::NewDocument();
NppXml::createNewDeclaration(_pXmlUserLangDoc._doc);
NppXml::createChildElement(_pXmlUserLangDoc._doc, "NotepadPlus");
}A new XML document, created here and never registered anywhere. NppParameters::destroyInstance is thorough about everything else, and its comment says exactly what it is relying on:
void NppParameters::destroyInstance()
{
delete _pXmlDoc._doc;
delete _xmlUserDoc._doc;
delete _pXmlUserStylerDoc._doc;
//delete _pXmlUserLangDoc; will be deleted in the vector
for (const auto& l : _pXmlUserLangsDoc)
{
delete l._udlXmlDoc;
}And that is true of the other place this member is assigned. At line 1573, during loading, the document is created and immediately pushed into _pXmlUserLangsDoc, so the vector really does own that one. The document created at line 4304 never reaches the vector, and nothing else frees it. Two assignment sites, one shared comment asserting who owns the result, and only one of them honoring it. That is the generalizable half of this bug, and it is worth grepping your own code for: an ownership rule that lives in a comment is a rule the second caller does not have to obey.
The other half is that it only triggers from a particular starting state: no userDefineLang.xml, but a userDefineLangs\ folder that is not empty, which is how a fresh installation arrives. Against a configuration directory with years of history on it, this one would not have shown up at all.
Which is the whole of the memory story: 199,112 bytes, two bugs, both on the way out the door, neither one of them able to grow. Now the part that can.
Now change the leak type
Everything so far came off the memory list. Switch Leak type to GDI objects and the same snapshot has six more rows in it. Six in Notepad++'s own code, that is; the filter above the list is holding back another 43 that belong to modules other than notepad++.exe.

Look at the Size column first: <Not Available>, on every row. A GDI handle has no size to report, so every ranking up to this point was blind to these six by construction, and an allocator-level leak dump would not have listed them at all.
Three device context rows, two bitmap rows and a brush, from four lines of Notepad++'s own code. The selected row is an HDC with a hit count of 6, and the stack lands in generateSolidColourMenuItemIcon at Notepad_plus.cpp line 9164, called from setupColorSampleBitmapsOnMainMenuItems at line 2783, called from Notepad_plus::init at line 487. Here is the function:
HBITMAP Notepad_plus::generateSolidColourMenuItemIcon(COLORREF colour)
{
HDC hDC = GetDC(NULL);
const int bitmapXYsize = 16;
HBITMAP hNewBitmap = CreateCompatibleBitmap(hDC, bitmapXYsize, bitmapXYsize);
HDC hDCn = CreateCompatibleDC(hDC);
HBITMAP hOldBitmap = static_cast<HBITMAP>(SelectObject(hDCn, hNewBitmap));
RECT rc = { 0, 0, bitmapXYsize, bitmapXYsize };
// paint full-size black square
HBRUSH hBlackBrush = CreateSolidBrush(RGB(0,0,0));
FillRect(hDCn, &rc, hBlackBrush);
DeleteObject(hBlackBrush);
// overpaint a slightly smaller colored square
rc.left = rc.top = 1;
rc.right = rc.bottom = bitmapXYsize - 1;
HBRUSH hColorBrush = CreateSolidBrush(colour);
FillRect(hDCn, &rc, hColorBrush);
DeleteObject(hColorBrush);
// restore old bitmap so we can delete it to avoid leak
SelectObject(hDCn, hOldBitmap);
DeleteDC(hDCn);
return hNewBitmap;
}This is not careless code. Both brushes are deleted immediately after use. The compatible DC is deleted. The old bitmap is selected back into it first, with a comment saying why: so we can delete it to avoid leak. Someone was thinking about exactly this class of bug while writing these lines, and got everything right except the very first one: GetDC(NULL) on line 9164 is never matched by a ReleaseDC.
The two hit counts, 6 and 5, are the two loops in the caller. setupColorSampleBitmapsOnMainMenuItems builds six icons for the search-marker styles and then five for the tab colors, and Deleaker grouped them separately because the stacks differ by one line. Eleven device contexts and eleven bitmaps, every launch.
The same mistake, once more and on its own, in NppParameters::setFontList:
lf.lfCharSet = DEFAULT_CHARSET;
lf.lfFaceName[0]='\0';
lf.lfPitchAndFamily = 0;
HDC hDC = ::GetDC(hWnd);
::EnumFontFamiliesEx(hDC, &lf, EnumFontFamExProc, reinterpret_cast<LPARAM>(&_fontlist), 0);
}The DC is acquired on the second to last line of the function and the function ends. One handle, once, at startup.
Twenty four GDI objects for a launch is not going to hurt anybody. The icon leak is not confined to launch, though, and that is where it stops being a curiosity.
Twenty right-clicks
Three call sites build those color icons. One is startup. One is the Style Configurator, which sends WM_UPDATEMAINMENUBITMAPS when you save, so every apply costs another eleven and eleven. The third is the tab context menu handler:
// Adds colour icons
for (int i = 0; i < 5; ++i)
{
COLORREF colour = nppParam.getIndividualTabColor(i, NppDarkMode::isEnabled(), true);
HBITMAP hBitmap = generateSolidColourMenuItemIcon(colour);
SetMenuItemBitmaps(_tabPopupMenu.getMenuHandle(), IDM_VIEW_TAB_COLOUR_1 + i, MF_BYCOMMAND, hBitmap, hBitmap);
}The menu itself is built once, behind an isCreated() guard about sixty lines above this. The icons are not inside it. They are rebuilt on every notification, which is to say every time you right-click a tab.
That is a claim about slope, and slope is what a baseline comparison measures. So: start Notepad++ under Deleaker, take a snapshot as soon as the window appears, right-click the document tab twenty times pressing Esc each time, close the application, and compare the post mortem snapshot against the baseline with Leak type set to GDI objects. The baseline already contains the eleven startup handles, which is the point: the comparison subtracts them and leaves only what those twenty right-clicks cost.

Two rows. One hundred HBITMAP and one hundred HDC. Five of each per right-click, twenty right-clicks, and the stack removes any doubt about where they came from: generateSolidColourMenuItemIcon at line 9166, Notepad_plus::notify at line 1152, and below that TabBarPlus::runProc and TabBarPlusProc. The tab bar is in the call stack.
There is a second leak hiding in that same loop, and the bitmap row is the evidence for it. SetMenuItemBitmaps does not take ownership of a bitmap; it stores the handle and leaves disposal to you. Nothing here deletes the previous set before installing a new one, so every rebuild orphans the bitmaps from the rebuild before it. That is why the count is 100 and not 5. The menu only ever displays five icons at a time, and the other 95 are the ones it used to display.
Nothing about this requires a profiler to confirm, incidentally, which is the nice thing about GDI. Turn on the GDI Objects column in Task Manager, sit on a tab with the right mouse button for a minute, and watch the number climb and never come down.
Two things that are not leaks
Every leak in this article is worth fixing, and I would say that about any leak you can reach. But two of the entries reported so far are not leaks at all, and telling those apart from the rest in a few seconds is most of what makes a report usable. One of them is not even on the GDI list.
The kernel handle list has a single entry, from winmain.cpp:
bool TheFirstOne = true;
::SetLastError(NO_ERROR);
::CreateMutex(NULL, false, L"nppInstance");
if (::GetLastError() == ERROR_ALREADY_EXISTS)
TheFirstOne = false;The return value is discarded on purpose. This is how Notepad++ detects a second instance, and the mutex has to exist for as long as the process does; the handle is not stored because there is no moment at which closing it would be correct. Reported, because Deleaker reports what was not freed rather than what was intended. Not a leak.
The HBRUSH is the same kind of thing: Notepad_plus_Window::init creates it as the hbrBackground of the WNDCLASS it registers, and a registered window class owns its background brush for the lifetime of the class.
Those two aside, nothing on the list earns a pass for being small, and this is the argument I would keep from all of this. "Small" is never a property of the code. It is a property of how the code happens to be called today, and generateSolidColourMenuItemIcon is the proof. Read it in isolation and its missing ReleaseDC costs eleven handles at startup: a rounding error, the sort of thing you defer forever. It became a leak that grows the day somebody wired the same function into a context menu handler. That was an ordinary change, obviously correct in itself, made by someone who had no way to know from reading the code that they had just turned a fixed cost into a per-click one. Nothing in the source announced it, and nothing would have.
Which is why the cheap ones are worth spending the line on while they are still cheap. It costs less than finding the same bug later from the other end, when it has become the reason a long session runs out of handles.
What to fix, and in what order
All five are worth fixing. What the ranking decides is the order, and ranking by slope rather than by size inverts the report:
First, the ReleaseDC at Notepad_plus.cpp line 9164: one line, before the return. It sits on the only code path here that grows with use, and it is the cheapest thing in the report to fix.
Then bitmap ownership for the menu icons, which is not a missing line. The previous set of handles has to be remembered somewhere so it can be deleted on the next rebuild, or the icons have to be built once and reused. A small design decision, and the second half of the same bug.
Third, the ReleaseDC at Parameters.cpp line 2060: one line, one handle, once. Free to fix, so fix it.
The two shutdown bugs come last. They hold the biggest numbers in the report, 33 KB of XML document and 166 KB of Scintilla documents, and they come last because their cost is capped rather than because the process is about to exit. Open one file or open fifty and the bill is settled once, at a moment when nothing further depends on the memory. The XML one is nearly a one liner. The FileManager one is not, because releasing a document requires a live Scintilla to release it through, so it has to move somewhere before the windows come down rather than into the static destructor where the current cleanup lives.
One word on what the ranking is not, because it is easy to read it as the usual excuse. "It gets freed when the process exits" is true of every leak in this article, true of every leak you will ever find, and for that reason it distinguishes nothing and excuses nothing. It is not why the shutdown bugs come fourth. They come fourth because their cost is bounded and known, four documents and one XML tree, settled once. Fourth is still on the list.
What the tracker already knew
Reporting the GDI leak meant searching the tracker for duplicates first, and that search turned out to be the most informative step in the whole exercise: the ranking above had already been tested by other people.
Both memory leaks were already there. Issue #17903, opened in March 2026 by a contributor called xomx, is the same two bugs found the other way round, with the debug CRT's own leak dump instead of a profiler. It even has a fix: pull request #17904 deletes the orphaned XML document, and releases the view's default document in ScintillaEditView::destroy(), which is precisely the "somewhere before the windows come down" I called the hard part a few paragraphs ago. Someone had already found the right place to put it. The PR was closed for want of a spare evening, four months before this snapshot, which is why both leaks are still in it.
The GDI leak is the other way about. Nothing in the tracker mentions generateSolidColourMenuItemIcon, and the only hit for ReleaseDC is an unrelated print preview bug. It has been leaking on every right-click since November 2020 and until I filed #18297 nobody had written it down.
The part of that worth taking home has nothing to do with issue trackers. The change that introduced it, pull request #9089, drew twenty seven review comments. Every one of them is about the user interface. Not one mentions the device context, in a function whose own comments show the author was thinking about leaks while writing it. Code review caught nothing here, and it was never going to: reviewers were looking at screenshots of menus, which is what the change was for.
So the dividing line between the two findings that somebody else caught and the one that nobody did is not how big they are. It is which instrument could see them. The two memory leaks are heap allocations, and a heap dump reports those. The third leaks device contexts and bitmaps, and no heap dump reports one of those at any size, in any order, however carefully it is read. I said as much at the top of this article on the strength of one afternoon's snapshot. The tracker has now tested it against six years and a good deal of other people's attention. The GDI leak did not rank low. It never appeared.
The same pass, on your own code
Nothing above depended on Notepad++ being Notepad++, so here is the whole procedure with the editor taken out of it. It took an afternoon.
Build so that symbols reach the binary, and point the program at a scratch profile so the starting state is one you control. Run it under the profiler, do nothing but start and close, and read the post mortem snapshot. Restrict the list to your own modules first, or you will spend your afternoon reading ntdll. Sort by allocation count and expect most of the top to be innocent: a count that matches a loop bound is a container's contents, not a leak, and freeing the one object that owns them clears the lot. Read each stack from the bottom up, because a stack that bottoms out in start-up code is a bounded cost and one that bottoms out in a message handler is not.
Then change the resource type and read the list again. This is the step that has no equivalent in a heap dump, and in this case it is the step that found the only leak that mattered.
Finally, for anything you suspect repeats, stop reading and measure it: snapshot, perform the action twenty times, snapshot again, and diff. That converts an argument about code into a number, and a number is what tells you whether you have found a fixed cost or a slope. It is also what makes the bug report credible, which matters whether you are filing against a public tracker or against your own.
There is a free trial if you want to run that pass on your own application.
What it was worth
Five things worth a patch out of a session that consisted of starting the program and closing it again, and only one of them new to anybody.
Notepad++ had every advantage in this that a closed codebase does not have. Its source is public. An outside contributor ran a leak dump against it, diagnosed two of these bugs and wrote the fix for both. The change that introduced the third went through twenty seven review comments before it shipped. With all of that going for it, the one leak that actually grows still survived close to six years, because nothing in that chain was watching anything but the heap.
Most of the code I get asked about has none of those advantages. Nobody outside the company is ever going to profile it, no stranger will open an issue against it, and the only leak report it will ever get is the one somebody inside goes looking for. Which is why the two questions this exercise kept coming back to are worth more there than they were here: not how many bytes, but which resource, and whether the number grows.
Postscript
I reported the GDI leak as #18297. xomx had a patch the same day, and it is on master now.
Six years unnoticed. Hours to fix, once somebody wrote it down.
