Help us improve Softanics
We use analytics cookies to understand which pages and downloads are useful. No ads. Privacy Policy
Artem Razin
August 5, 2026

How I hunted memory leaks in HeidiSQL

HeidiSQL is an open source client for MariaDB, MySQL, Microsoft SQL Server, PostgreSQL, SQLite and Firebird, written in Delphi and maintained since 2002 by its author, Ansgar Becker. That makes it a good subject for this: it is large, it is real software that people use every day, and anyone can check every claim below against the source. I built it with debug symbols, ran it under Deleaker, used it the way you would use any database client, and closed it.

A word on the tool. Deleaker finds leaks in native Windows applications, memory and GDI, USER and kernel handles alike, and runs standalone or inside RAD Studio. In Delphi and C++Builder it tracks objects, so a leak comes back as a class name with a call stack instead of an anonymous heap block. There is a free trial if you want to repeat this on your own code.

The verdict comes first, because it deserves saying. The codebase came out of this well. A full session left thirty five unfreed Delphi objects totalling under 7 KB, and most of them are globals created once at startup, which is a fixed cost that stays the same whether the session lasts a minute or a working day. For a GUI application of this size and age, that is a good result.

What is left over is still worth having. Two of the leaks repeat: one drops four TStringList instances every time a form restores its column layout, and another abandons an object on every refresh of the session list. Neither is big enough to show up in Task Manager, which is exactly why they survived this long, and neither is the kind of thing you find by reading the code, because nothing about the code looks wrong. That is the argument for pointing a leak detector at a project you already trust, every once in a while, and seeing what falls out.

Building a version you can profile

I use RAD Studio 12.2 Athens and HeidiSQL 12.21. The readme asks for Delphi 12.1 or newer, and the repository ships project folders for Delphi12.1 and Delphi12.3; 12.2 compiles the Delphi12.3 projects unchanged. Two things stopped the first build. heidisql.dpr lists madExcept in its uses clause and I don't have madCollection installed, so I deleted the line. And the build driver is build.php, which needs a PHP interpreter I don't have, so I called dcc64 directly.

Which is where the real problem shows up: the stock command line produces a release build. Optimization is on, -V is never passed, and -U points at lib\win64\release. $D+ and $L+ are on by default, so it looks like a debug build, and it is one as far as the .dcu files are concerned, but without -V nothing reaches the executable.

For a Delphi binary that costs you more than readable stacks. Deleaker needs the symbols while it is attaching, not only when it prints a report. It tracks Delphi objects by hooking RTL routines such as TObject.InitInstance, and it locates them through the debug information. With nothing to hook, nothing is reported as a Delphi object at all, which in a Delphi application is most of what you came to look at. Pointing -U at the debug RTL matters for the same reason, and it pays off again in the report: System.pas and Vcl.Forms.pas are where a good half of any Delphi allocation stack runs.

Three changes fix that. -$O- turns optimization off, -V writes the debug information into the executable, and -U points at lib\win64\debug so the RTL and the VCL carry symbols too. Keep -$W+ from the original flags, since stack frames are what let a profiler walk a call stack at all.

The resources have to be compiled first: heidisql.dpr includes .RES files that the repository does not ship, only the .rc sources they are built from. Save this next to the repository and run it from the repository root:

@echo off
set BDS=C:\Program Files (x86)\Embarcadero\Studio\23.0
 
"%BDS%\bin\brcc32.exe" res\version.rc
"%BDS%\bin\cgrc.exe"   res\icon.rc
"%BDS%\bin\brcc32.exe" res\icon-question.rc
"%BDS%\bin\brcc32.exe" res\manifest.rc
"%BDS%\bin\brcc32.exe" res\updater.rc
"%BDS%\bin\cgrc.exe"   res\styles.rc
"%BDS%\bin\brcc32.exe" source\vcl-styles-utils\AwesomeFont.rc
"%BDS%\bin\brcc32.exe" source\vcl-styles-utils\AwesomeFont_zip.rc
 
cd packages\Delphi12.3
 
"%BDS%\bin\dcc64.exe" --no-config -B -Q -$W+ -$O- -V ^
  -NS"Vcl;System;Winapi;System.Win;Data" ^
  -I"..\..\source" ^
  -R"..\..\components\synedit\Source;..\..\components\virtualtreeview\Source" ^
  -U"%BDS%\lib\win64\debug;..\..\components\virtualtreeview\Source;..\..\components\synedit\Source;..\..\source\detours\Source;..\..\source\vcl-styles-utils;..\..\source\sizegrip" ^
  -N0"..\..\build\Win64" ^
  -E"..\..\out\Debug" ^
  --high-entropy-va:off ^
  heidisql.dpr
 
cd ..\..
move /y out\Debug\heidisql.exe out\Debug\heidisql64.exe

That gives you out\Debug\heidisql64.exe with the symbols inside it. The rename at the end is only to match what build.php would have called it. HeidiSQL loads its client libraries from the directory it runs in, so copy the DLLs and the plugins64, locale and Snippets folders from out\ alongside it if you want to connect to a real server.

If you would rather not carry the symbols in the binary, swap -V for -VT and they go to a separate heidisql64.tds beside it instead. Deleaker reads either form, as long as the .tds stays next to the executable.

Now allocation stacks resolve down to the unit and the line, which is where the hunt starts.

Taking the snapshot

Deleaker launches the binary itself. Point Command at out\Debug\heidisql64.exe, press Start Debugging, and work with the application.

Two strategies are worth knowing from here. Close the app and Deleaker captures a post mortem snapshot, which is 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 work, and compare a later snapshot against it. The difference is what that work left behind. The later snapshot can be the post mortem one, which combines the two.

The two answer different questions, and the second one is sharper. A post mortem list mixes a global that is allocated once and never released, which costs you a fixed few bytes, with a leak that repeats on every use, which is the one that actually hurts. Comparing snapshots separates them immediately: the baseline subtracts away everything that was already there, and what is left came from the work you did.

Either way, sort by count before you read anything else. On the allocation list that column is Hit Count, the number of times one call stack allocated; on the Delphi Objects tab it is Object Count, the number of live instances of a class. A big number means a code path ran over and over and left something behind every time, which is exactly the shape of a leak worth chasing.

Allocations sorted by hit count

It is not proof of one, though. This run leaked 1156 memory blocks, and the top of the sorted list is mostly entries a developer dismisses in seconds. The largest, 662 allocations of 23 KB in total, is HeidiSQL splitting its MySQL keyword list in dbstructures.mysql.pas line 3447, and you don't even need to know the codebase to say so: the stack bottoms out in InitUnits and heidisql.dpr line 74, which means unit initialization, which means once at startup. The 244 below it is the VCL streaming a form. The <Unknown> rows are inside ntdll.dll and uxtheme.dll and belong to Windows. What survives that pass is a short list, and that is the whole value of the sort.

The other tab worth opening straight away is Delphi Objects, which groups the same snapshot by class. This is the payoff from the hooks described earlier: not anonymous heap blocks, but named classes with counts.

Leaked Delphi objects in Deleaker

The whole set adds up to under 7 KB, and the size is not what makes it interesting. What makes it interesting is that every row carries a stack. The TRegExpr selected here runs from the RTL allocator through the VCL and lands in HeidiSQL's own code: TRegExpr.Create at SynRegExpr.pas line 1954, called from TMainForm.SetDelimiter at main.pas line 5404, called from TMainForm.FormCreate at line 2020. That is a bug report with an address on it.

The rest of this article reads the post mortem list first, because it needs no planning, and returns at the end to a run measured against a baseline snapshot, which answers the sharper question.

Where to start

Start on the Delphi Objects tab, not on Allocations. Most of what the allocation list shows in a Delphi program is not a leak in its own right, it is the contents of one: the strings inside a TStringList nobody freed, the dynamic array behind its capacity. Free the list and all of that goes with it. Thirty five objects against 1156 allocations, and fixing one row on the left usually clears a dozen on the right.

Then sort by Object Count and read from the top. A single instance is usually a global, created once and never released, costing a fixed few bytes for the life of the process. Several instances of one class means a code path ran more than once and left something behind every time. In this snapshot everything sits at one except two TRegExpr and sixteen TStringList, so there is no question about where to look first.

The way to read each stack is from the bottom up. If it bottoms out in InitUnits or in TMainForm.FormCreate, the allocation happened once while the program was starting, and the leak is bounded however long the application runs. Annoying, worth fixing, but it will not grow as long as it stays where it is. If it bottoms out in a handler that fires on user action, it repeats, and it keeps repeating for the whole session.

That single test splits the sixteen TStringList instances in about a minute. Most of them bottom out in startup code and go on the bounded pile. Four do not. They come through TExtForm.RestoreListSetup, called from Tconnform.FormShow, which runs every time the session manager opens. Four separate leaks inside one procedure is not a coincidence, and it is reason enough to go and read the procedure.

Reading the code

Four lists, one Free

Deleaker put the four TStringList allocations at lines 321, 328, 342 and 354 of source/extra_controls.pas. Here is the head of the procedure:

  ValueList := TStringList.Create;
 
  // Column widths
  OwnerForm := GetParentFormOrFrame(List);
  Regname := OwnerForm.Name + '.' + List.Name;
  Value := AppSettings.ReadString(asListColWidths, Regname);
  if Value <> '' then begin
    ValueList := Explode( ',', Value );
    for i := 0 to ValueList.Count - 1 do
    begin
      ColWidth := MakeInt(ValueList[i]);
      ColWidth := RoundCommercial(ColWidth * OwnerForm.ScaleFactor);
      // Check if column number exists and width is at least 1 pixel
      if (List.Header.Columns.Count > i) and (ColWidth > 0) and (ColWidth < 1000) then
        List.Header.Columns[i].Width := ColWidth;
    end;
  end;
 
  // Column visibility
  Value := AppSettings.ReadString(asListColsVisible, Regname);
  if Value <> '' then begin
    ValueList := Explode( ',', Value );

The same block repeats twice more, for column positions on line 354 and for the sort column on line 367, and then the procedure ends:

  // Sort column and direction
  Value := AppSettings.ReadString(asListColSort, Regname);
  if Value <> '' then begin
    ValueList := Explode(',', Value);
    if ValueList.Count = 2 then begin
      List.Header.SortColumn := MakeInt(ValueList[0]);
      if MakeInt(ValueList[1]) = 0 then
        List.Header.SortDirection := sdAscending
      else
        List.Header.SortDirection := sdDescending;
    end;
  end;
 
  ValueList.Free;
end;

Explode hands back a brand new list every time it is called:

function Explode(Separator, Text: String): TStringList;
var
  i: Integer;
  Item: String;
begin
  // Explode a string by separator into a TStringList
  Result := TStringList.Create;

The diagnosis writes itself. Five lists are created and one is freed. Every assignment to ValueList drops the previous instance on the floor, and the single ValueList.Free on line 377 reaches only the last one. Up to four leak per call, along with every string and dynamic array they hold.

The snapshot proves it in a way that is worth pausing on: there are leaked allocations from lines 321, 328, 342 and 354, and not one from line 367. Line 367 is precisely the assignment that survives to reach the Free. The tool did not just find a leak, it drew the exact shape of the bug.

This is also the leak with the most reach. RestoreListSetup is called from fifteen places, and every one of them runs when a form opens.

A destructor that knows better

The two TRegExpr objects, 1480 bytes each and the largest single class in the snapshot, come from two different places. One is in TAppSettings.Create:

  rx := TRegExpr.Create;
  rx.Expression := '^\-\-?psettings\=(.+)$';
  for i:=1 to ParamCount do begin
    if rx.Exec(ParamStr(i)) then begin
      FSettingsFile := rx.Match[1];
      break;
    end;
  end;

A local object, used and abandoned. It leaks the TRegExpr itself plus its expression string and its character checker array, which is why one line of source produces three rows in the snapshot.

What makes this one an oversight rather than a decision is that the destructor of the same class builds the same kind of local TRegExpr four hundred lines later, on line 4202, and disposes of it properly:

    FRegistry.CloseKey;
    CloseHandle(SnapShot);
    AllKeys.Free;
    rx.Free;

It costs a fixed 1.5 KB per process, which is nothing today. It is still a bug, and it takes one line to fix.

The one you can live with

Not everything in the report costs the same, and it is worth being able to say which is which in a few seconds. The snapshot lists a TObjectList<TDBLogItem> allocated in the program block itself, heidisql.dpr line 76:

begin
  PostponedLogItems := TDBLogItems.Create(True);

One caveat on that line number. The screenshots above show heidisql.dpr at lines 74 and 75, because the build in this article has the madExcept line deleted from the uses clause, and everything below it moves up by one. Upstream, begin is line 75 and this call is line 76. It is the only file in the article where the two disagree.

The list is created once, used for the life of the program, and never freed. It is created with ownership of its items and Clear is called on it while the application runs, so the objects inside it are released; the 96 byte list object is what survives. This is the category to recognize and send to the bottom of the list: a process lifetime global that costs the same 96 bytes no matter what the user does. It shows up because Deleaker reports what was not freed, not what the author intended. Being able to sort this kind from the rest in a few seconds is what keeps the report useful.

The bottom of the list is not the same as off it. What makes this one cheap is not the code, it is an assumption about how the code is used: allocated once, at startup, for the life of the process. Nothing in the source enforces that assumption and nothing announces when it stops being true. Move the allocation into something that runs per connection or per opened tab, an ordinary refactoring nobody would think twice about, and the same line leaks on every call. Whoever does it will not know, because there is nothing in the code to see.

Which is the argument against the reflex of writing a leak off because the process is going to exit anyway. That is true of every leak in this article and of every leak you will ever find, and it says nothing about which of them will still be harmless after the next refactoring. You can live with this one. It is cheaper to spend the one Free now than to find it again from the other end, when it has become the reason a long session runs out of memory.

An object with no owner

Tconnform.RefreshSessions leaks twice, and the second one is a different animal:

begin
  // Initialize session tree
  // And while we're at it, collect custom colors for background color selector
  if ParentNode=nil then begin
    ListSessions.Clear;
  end else begin
    ListSessions.DeleteChildren(ParentNode, True);
  end;
  SessionNames := NodeSessionNames(ParentNode, RegKey);
  for i:=0 to SessionNames.Count-1 do begin
    Params := TConnectionParameters.Create(RegKey+SessionNames[i]);
    SessNode := ListSessions.AddChild(ParentNode, PConnectionParameters(Params));

The procedure runs to line 371 and frees neither.

SessionNames is the familiar case: a list returned by a function, used, never freed. The procedure calls itself recursively for folders, so it leaks once per folder in the tree.

Params is more interesting. It is not a missing Free, it is a missing owner. The object is handed to AddChild as node data, and a TVirtualStringTree stores that pointer without taking responsibility for the object behind it. Freeing it is the caller's job, normally through an OnFreeNode handler, and connections.pas does not have one. So ListSessions.Clear on line 351 discards the pointers and the objects stay behind. One per session, on every refresh, not only at shutdown.

That is the failure mode a leak detector is genuinely good at. Nothing in the source looks wrong. There is no allocation without a matching Free sitting a few lines below it. You only find it by being told, after the fact, that the object outlived the process.

What a few minutes of work cost

Everything so far comes from a deliberately thin run: start HeidiSQL, let the session manager appear, close it. Thirty five objects and four bugs worth writing down.

The second run asks a narrower question. Start the application, take a snapshot before touching anything, open a SQLite file, click a table so the editor loads it, and exit. Then compare the final state against that baseline instead of reading it as a flat list. What is left is the cost of that specific piece of work, with every startup global subtracted away.

Comparing the post mortem snapshot against the baseline

1153 objects, about 190 KB. None of it is a global that was always going to be there, because the baseline removed those. All of it was created and abandoned between opening a database and shutting down.

A form that frees nothing

The two largest rows, 493 TStringList and 190 TTableColumn, both lead back to the table editor. Its constructor in source/table_editor.pas builds six containers:

begin
  inherited;
  comboRowFormat.Items.CommaText := 'DEFAULT,DYNAMIC,FIXED,COMPRESSED,REDUNDANT,COMPACT';
  comboInsertMethod.Items.CommaText := 'NO,FIRST,LAST';
  FColumns := TTableColumnList.Create;
  FKeys := TTableKeyList.Create;
  FForeignKeys := TForeignKeyList.Create;
  FDeletedKeys := TTableKeyList.Create;
  FDeletedForeignKeys := TStringList.Create;
  FDeletedCheckConstraints := TStringList.Create;

The unit has no destructor. Nothing ever frees them, and they own what they hold, so the bill is not six objects but everything the editor parsed: 190 TTableColumn, 77 TTableKey, 41 TStringMap, 26 TDBObject, and the 34 TForeignKeyList and TTableColumnList instances holding them.

Each TTableKey then builds three lists of its own, in dbconnection.pas:

constructor TTableKey.Create(AOwner: TDBConnection);
begin
  inherited Create;
  FConnection := AOwner;
  Columns := TStringList.Create;
  SubParts := TStringList.Create;
  Collations := TStringList.Create;

Which is 231 of the 493 leaked lists, from that one constructor. The stack in the screenshot spells out how they got there: clicking a node in the database tree calls DBtreeFocusChanged, then PlaceObjectEditor, then TfrmTableEditor.Init on line 442, then GetTableKeys, then TTableKeyList.Assign, then this. Every click on a different table does it again.

Init also leaks on its own account. On line 398 it creates a TRegExpr to pull attributes out of the table's CREATE statement, holds it in a local declared on line 324, and never frees it. Eleven of those, 16 KB between them, which makes TRegExpr the most expensive class per instance in the run.

The same unit leaks a third way. GetKeyImageIndexes hands back a newly created list:

function TfrmTableEditor.GetKeyImageIndexes(Col: TTableColumn): TList<Integer>;
var
  idx, i: Integer;
begin
  Result := TList<Integer>.Create;

92 of those leaked, from two call sites on lines 1358 and 1386, neither of which frees the result. It is Explode again under a different name: a function that returns an object, and callers that read it like a property and move on. A codebase with one of these usually has more, and grepping for Result := T followed by .Create finds them faster than a profiler will.

One constructor, three leaks

TSQLFunction is next at 76, and all of them come from a single constructor in dbconnection.pas:

  TryFiles := Explode(',', SQLFunctionsFileOrder);
  for TryFile in TryFiles do begin
    IniFilePath := GetAppDir + 'functions-'+TryFile+'.ini';
    FOwner.Log(lcDebug, 'Trying '+IniFilePath);
    if FileExists(IniFilePath) then begin
      FOwner.Log(lcInfo, 'Reading function definitions from '+IniFilePath);
      Ini := TMemIniFile.Create(IniFilePath);
      Sections := TStringList.Create;
      Ini.ReadSections(Sections);
      for Section in Sections do begin
        SQLFunc := TSQLFunction.Create;

TSQLFunctionList reads HeidiSQL's functions-sqlite.ini and turns every section into an object. Three separate leaks live in these eleven lines: TryFiles is another unfreed Explode result, Sections is never freed, and the list itself is assigned to a field that nobody releases:

  FSQLFunctions := TSQLFunctionList.Create(Self, SQLFunctionsFileOrder);

FSQLFunctions is declared on line 498, assigned here inside DoAfterConnect, published as a read only property on line 633, and freed nowhere in the unit. It is constructed with inherited Create(True), so it owns its contents, and one Free in the connection's destructor would clear the list and all 76 objects in it. Being in DoAfterConnect means it happens per connection, not once per process.

The bug from the first run, at scale

RestoreListSetup ran fifty times in this session and leaked 200 TStringList instances: fifty from line 321, and fifty from each of lines 328, 342 and 354. In the first run it was four. Nothing about the bug changed, only how much of the application was used.

That is the difference between a leak worth fixing and a global you can ignore, and it is the reason to spend the extra thirty seconds on a baseline snapshot. A flat post mortem list would have shown all of this mixed in with the keyword lists, the icon and the settings object from the first half of this article, and left you to sort it out by hand. The comparison throws those away up front and answers the question you actually had: I did this, what did it cost?

190 KB for opening one database and clicking one table. The number is not alarming. Its slope is.

Reported upstream

Finding a leak is the easy half. Everything in this article went to Ansgar Becker as issue #2574 before a word of it was published, with the file, the line and a proposed fix for each finding.

The report is split the way the findings split. One group is unambiguous and costs a line each: the four dropped lists in RestoreListSetup, the TRegExpr in TAppSettings.Create, the TList<Integer> results from GetKeyImageIndexes, TryFiles and Sections in TSQLFunctionList.Create. There is nothing to decide about those.

The other group is not a missing Free at all, it is a question about ownership, and only the maintainer can answer it: who releases a TConnectionParameters once it has become node data, whether the table editor should have a destructor for its six containers, where FSQLFunctions belongs in a connection's lifetime. Each comes with a suggestion, but they are design calls rather than typos, and a patch sent without asking would have been the wrong way round.

The order was deliberate. Findings like these are worth more in a tracker than in an article.