DataGridView memory leakage when removing rows with RemoveAt
.NET version
.NET 9.0
Did it work in .NET Framework?
Yes
Did it work in any of the earlier releases of .NET Core or .NET 5+?
No response
Issue description
I have a specific usage pattern with a DataGridView where I pre-create a set of DataGridViewRow instances and then dynamically add or remove these rows from an existing DataGridView at runtime.
Here’s how I currently manage rows:
-
Add– I use this to add pre-createdDataGridViewRowobjects to theDataGridView. This works as expected. -
Clear– I use this to remove all rows at once. This also works correctly and does not seem to cause any issues. -
RemoveAt– I frequently use this to remove rows at specific indices. However, I've noticed that repeated use ofRemoveAtappears to cause memory leaks over time.
This suggests that RemoveAt may not be fully disposing of internal row resources or references.
Steps to reproduce
I’ve created a minimal reproducible codebase that demonstrates the issue:
I'm observing unexpected memory behavior after adding and removing DataGridViewRow objects repeatedly. Specifically, memory snapshots reveal a buildup of tooltip-related objects even after rows are removed.
📸 Memory Snapshot Before Hide/Show:
📸 Memory Snapshot After One Hide/Show Cycle:
As shown, there's a significant increase in:
-
ConditionalWeakTable+Entry<IKeyboardToolTip, WeakReference<ToolTip>>[] -
WeakReference<ToolTip>
These objects appear to accumulate with each hide/show cycle, and may not be getting released properly, potentially indicating a memory leak related to tooltip handling in DataGridViewRow.
I'm looking for guidance on:
-
Whether this is a known issue with
DataGridViewtooltip internals. -
Recommended practices to avoid or mitigate this kind of memory retention when programmatically adding/removing large numbers of rows.
This is not a leak. All these objects are dead already (you yourself checked the "shoe dead obj." checkbox) and they will be cleared during subsequent GC calls.
You can check it by adding this code after clearing rows in your BtnToggleRows_Click method:
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
Ikr, in my real project, I’ve been calling GC.Collect() after any bulk RemoveAt operations to force cleanup, but it definitely comes with a noticeable performance cost and is not recommended out there as well. The strange thing is that garbage doesn’t seem to be collected until system memory usage gets really high — I’ve seen it exceed 3GB on my 32GB machine before any cleanup occurs, which severely impacts performance. What puzzles me is why calling Clear immediately frees those dead objects, but RemoveAt doesn’t seem to trigger the same immediate cleanup. Since I’m not creating any new objects, I wouldn’t expect memory usage to get that high. Any ideas why this might be happening?
Ok we have ~2~ problems here:
-
The strange thing is that garbage doesn’t seem to be collected until system memory usage gets really high — I’ve seen it exceed 3GB on my 32GB machine before any cleanup occurs, which severely impacts performance.
We need a repro demonstrating this behavior and after that someone from core team I think...
-
What puzzles me is why calling
Clearimmediately frees those dead objects, butRemoveAtdoesn’t seem to trigger the same immediate cleanup.
I think this is a consequence of iterative manipulations with each row - this need more investigation... UPD. This is normal see explanation in my next post.
I’ve been calling
GC.Collect()after any bulkRemoveAtoperations to force cleanup, but it definitely comes with a noticeable performance cost and is not recommended out there as well.
If you have such a problems and you are sure that they are not happening due to your mistake then you need to workaround it 🤷♂️ And about performance - call GC in a new task after your bulk rows removal.
I think this is a consequence of iterative manipulations with each row - this need more investigation...
Yes the problem is in touching each row - this will unshare all rows in grid. Try this code for clear:
for (int i = 0; i < dataGridView1.Rows.Count; i++)
{
var r = dataGridView1.Rows[i];
}
dataGridView1.Rows.Clear();
and you will get +- same behavior as with RemoveAt.
I've already investigated the shared row suggestion, but it turns out that isn't the root of the issue.
After spending several hours reviewing the DataGridView code, I’ve identified the actual source of the memory growth. The problem lies in how the ToolToTipDictionary is implemented in the KeyboardToolTipStateMachine.
Reference – GitHub source
To demonstrate this, I’ve created a minimal reproducible example: 📁 WinFormsApp1.zip
private void BtnToggleRows_Click(object sender, EventArgs e)
{
for (int i = 1; i <= 1_000_000; ++i)
{
toolToTipDictionary[cell] = toolTip; // called on OnAddingRow
toolToTipDictionary.Remove(cell, toolTip); // called on OnRemovingRow
}
}
In the sample, clicking a button adds the <DataGridViewTextBoxCell, ToolTip> pair to ToolToTipDictionary one million times. Even though the Remove method from ConditionalWeakTable is indeed being called, memory usage still increases significantly with each click and doesn't release as expected.
While I’m not certain if this behavior qualifies as a bug, I wanted to report it in case there's a better workaround or a fix can be considered upstream.
After inspecting the core source code, I noticed a potential issue that could explain why dead objects are being kept in memory. Specifically, the Remove method, which removes entries from the table, does not immediately release memory for the removed objects. Instead, it just sets the entry’s hash code to -1 and marks the key as cleared, but does not free the associated resources or memory right away.
/// <summary>Removes the specified key from the table, if it exists.</summary>
internal bool Remove(TKey key)
{
VerifyIntegrity();
int entryIndex = FindEntry(key, out _);
if (entryIndex != -1)
{
RemoveIndex(entryIndex);
return true;
}
return false;
}
private void RemoveIndex(int entryIndex)
{
Debug.Assert(entryIndex >= 0 && entryIndex < _firstFreeEntry);
ref Entry entry = ref _entries[entryIndex];
// We do not free the handle here, as we may be racing with readers who already saw the hash code.
// Instead, we simply overwrite the entry's hash code, so subsequent reads will ignore it.
// The handle will be free'd in Container's finalizer, after the table is resized or discarded.
Volatile.Write(ref entry.HashCode, -1);
// Also, clear the key to allow GC to collect objects pointed to by the entry
entry.depHnd.UnsafeSetTargetToNull();
}
Potential Problem
This approach might lead to significant memory retention if the table grows to a large size. Specifically:
-
Large Capacity: If the table grows to millions of entries (for example, a table with a capacity of around
2^29or 500 million entries), the memory that should be freed might not be reclaimed until theContaineris resized or discarded. -
Deferred Cleanup: The deferred cleanup process means that memory for deleted entries isn't immediately available for garbage collection, causing dead objects to persist in memory.
Since it’s related to another dependency, I understand that it might be challenging for ToolToTipDictionary to handle this. For now, I think I'll stick with my current approach of calling GC.Collect() to clean up the dead objects. 😊
EDIT:
Sorry, I made a mistake in my earlier analysis. After further debugging, it turns out that Capacity is not the issue — it consistently remains at InitialCapacity, which is 8.
However, the memory leaks still appear to involve ConditionalWeakTable+Entry, as previously mentioned. I suspect the finalizer of the Container class may not be executing correctly. That said, this is just a hypothesis at this point, since I haven't yet tested it within the full DataGridView context.
Thank you for this detailed investigation. I'm glad you have a workaround for the moment. I'll chat with the team and see where we might be able to make some optimizations. That said, this doesn't look like something we're able to address right away. You're welcome to keep up investigating and/or submit a PR with a proposed fix.