MediumA zsh bug can truncate Terminal history | by Antonio Troise | Medium
Sitemap

A zsh bug can truncate Terminal history

13 min readAug 16, 2026

--

It has happened to me several times that I searched the Terminal history for a command I was convinced I had run on my Mac, only to be unable to find it. Usually, it was exactly the kind of command for which history becomes indispensable: a long ffmpeg invocation, an rsync command with a combination of parameters I could no longer remember, some find command put together after several attempts, a sequence used to launch a Python project, or something I routinely retrieved with Ctrl+R. Each time, I ended up finding a plausible explanation. Maybe I had run it on the other computer, maybe I was connected via SSH and that history belonged to the remote machine, maybe I was using another shell at the time, or perhaps the command was in one of the many iTerm2 tabs I keep open at the same time. There was also the simplest possibility, that I was remembering incorrectly.

That is why I read with particular interest the investigation published by Michael Stapelberg on August 9, 2026, Tracking down a Zsh history data loss bug, in which he describes a problem that had been happening to him for years: commands he was sure he had run were disappearing from zsh history. Pressing Ctrl+R would no longer find them and, after checking ~/.zsh_history, Stapelberg discovered that entire recent periods were missing. The file was still readable, with no strange characters or obviously corrupted lines. It simply contained much less history than before.

The symptom was similar enough to what I had observed on my Mac to make me think I had finally found an explanation. After looking more closely at how the bug works, I checked my configuration, and things turned out to be more interesting: on my Mac I am indeed using a version of zsh that, based on the Apple source code currently available, still contains the code from before the fix. At the same time, I discovered two characteristics of my configuration that can explain many of the apparently missing commands without having to blame the bug. My persistent history contains only about a thousand events and, since I often use around ten iTerm2 tabs at the same time, the various shells do not share their histories with each other in real time.

So yes, the bug affects my Mac. Whether it caused at least one of the losses I remember is another matter.

How zsh can replace a complete history with an incomplete one

zsh history is stored in the file specified by the $HISTFILE variable. In my usual iTerm2 session, it is simply:

echo "$HISTFILE"

/Users/antonio/.zsh_history

Two variables instead control the size of the history:

echo "HISTSIZE=$HISTSIZE"
echo "SAVEHIST=$SAVEHIST"

HISTSIZE=2000
SAVEHIST=1000

According to the zsh documentation, HISTSIZE indicates the maximum number of events kept in the shell's internal history, while SAVEHIST determines how many events are retained in the file. It is important to keep the two separate because what a shell knows while it is running may differ from what has been written to ~/.zsh_history at that point.

When zsh needs to rewrite the history file, it normally uses HIST_SAVE_BY_COPY, an option enabled by default. The official documentation describes the mechanism: zsh creates a $HISTFILE.new file, writes the new version of the history to it, and finally renames it over the previous file. The procedure is specifically intended to prevent an interruption during writing from destroying the original file.

The bug found by Stapelberg occurs in the stage before writing. During certain rewrite operations, zsh rereads the contents of the existing history through readhistfile(). The function can be interrupted by a **SIGINT** signal, which is the signal we normally send by pressing **Ctrl+C**. The code recognizes this situation and records that the read was interrupted, but in versions affected by the problem, the function handling the save operation can proceed anyway.

Imagine a .zsh_history containing 100,000 events. zsh starts reading it and reaches, for example, event 31,742. At that moment it receives **SIGINT**, so **readhistfile()** stops reading. Only the portion processed up to that point remains in memory. The code from before the fix can take that partial history, write it to **.zsh_history.new** and finally rename it over the original file.

The resulting file is syntactically valid. It does not necessarily contain half-written lines, random bytes, or other obvious signs of corruption, so the user continues using the shell normally until they search for something that belonged to the missing portion. This is one of the reasons why a bug like this can remain invisible for a long time.

In the zsh source code currently published by Apple, the problematic sequence is still visible. After calling readhistfile(), the code proceeds as follows:

readhistfile(fn, err, 0);
hist_ignore_all_dups = isset(HISTIGNOREALLDUPS);
if (histlinect)
savehistfile(fn, err, 0);

In the same version of hist.c, readhistfile() already contains the code that recognizes an interrupt and sets lasthist.interrupted:

lasthist.interrupted = 1;
break;

The fix prepared by Bart Schaefer and later integrated into the official version of zsh adds the missing check:

if (errflag & ERRFLAG_INT)
ret = -1;
else if (histlinect)
savehistfile(fn, err, 0);

If the read was interrupted, the second write is no longer performed. It is a tiny change when viewed in isolation, but it prevents a history that was only partially read from being used to replace the complete one.

How a bug triggered by pressing Ctrl+C at the wrong moment was found

Stapelberg’s investigation is worth recounting because the initial symptom provided very few clues. It was unclear whether zsh was incorrectly modifying the file, whether some other program was involved, or whether the loss depended on the interaction between multiple zsh processes. Stapelberg therefore began monitoring filesystem operations with inotify and fatrace, eventually observing the cycle in which zsh reads .zsh_history, creates .zsh_history.new, writes the new contents, and replaces the old file. He then moved on to bpftrace, also recording the amount of data read and written by the various processes.

When the problem occurred again, the trace showed something unusual: zsh had read only part of the file without reaching EOF, then wrote exactly that reduced amount of data to .zsh_history.new and replaced the previous history. At that point it was clear that the loss was occurring inside zsh, but the reason why readhistfile() stopped reading before reaching the end was still unknown.

To capture the program’s internal state, Stapelberg modified a copy of zsh 5.9.1 so that it would deliberately crash if it was about to produce a suspiciously short .zsh_history.new. After a few days the problem occurred, and the core dump finally revealed the values he needed: errflag indicated the presence of an interrupt, while lasthist.interrupted confirmed that reading the history had been stopped.

The remaining question was where SIGINT came from. The answer depended on one of the author's everyday habits. Stapelberg uses many SSH and mosh sessions and, when he finishes working, tends to close them quickly by alternating Ctrl+D and Ctrl+C. Ctrl+D, when interpreted as EOF, can cause the shell to exit; while exiting, zsh may begin handling the history file. If Ctrl+C arrives a few moments later while readhistfile() is still reading, the condition that triggers the bug occurs.

Stapelberg’s configuration made this scenario more likely because he had:

HISTSIZE=4000
HISTFILE=~/.zsh_history
SAVEHIST=10000000

setopt HIST_IGNORE_DUPS
setopt INC_APPEND_HISTORY
unsetopt SHARE_HISTORY

His **.zsh_history** was therefore very large, on the order of tens of megabytes. Reading a file that large takes enough time for the window in which **Ctrl+C** can interrupt the operation to become realistically reachable by a person rapidly pressing a few keys. With a history file of only a few tens of kilobytes, like mine, the same read takes much less time and the probability of hitting that exact interval is considerably lower.

The bug was reported to the zsh mailing list in March 2025. The upstream patch arrived in April of the same year, but was not included in the subsequent 5.9.1 release. zsh 5.9.2, released on July 12, 2026, finally contains the fix, as Stapelberg also notes at the beginning of his account.

I checked my Mac and found three overlapping histories

After reading the article, I checked which zsh I was using:

command -v zsh
zsh - version
/bin/zsh - version

/bin/zsh
zsh 5.9 (arm64-apple-darwin25.0)
zsh 5.9 (arm64-apple-darwin25.0)

I am therefore using the zsh provided directly by Apple. Apple’s open source repository still identifies the version as 5.9 and, more importantly, the [hist.c](https://raw.githubusercontent.com/apple-oss-distributions/zsh/refs/heads/main/zsh/Src/hist.c) published by Apple still contains the sequence from before the patch. The 5.9 version number alone would not be enough to establish that the bug is present, because Apple could apply a later fix as a backport without changing the version number. Checking the source code is precisely what avoids jumping to that conclusion too quickly.

During my initial checks, I also came across a detail specific to macOS. In a Terminal.app window:

echo "$HISTFILE"

/Users/antonio/.zsh_sessions/2AAD2620–9FB4–4F3F-B77A-50E1EEBCF93B.historynew

Apple’s Terminal.app has its own mechanism for preserving sessions and can use files under ~/.zsh_sessions.

That result was correct, but it did not describe my usual environment because I often work with iTerm2. Repeating the test there gave me:

TERM_PROGRAM=iTerm.app
TERM_SESSION_ID=w0t0p0:B3501E09-D655–49CF-8A2D-182521E78774
HISTFILE=/Users/antonio/.zsh_history
HISTSIZE=2000
SAVEHIST=1000

In iTerm2, my history is therefore directly stored in ~/.zsh_history. At the time I checked it on August 16, the file had these characteristics:

-rw - - - - 1 antonio staff 62K 8 ago 08:00 /Users/antonio/.zsh_history
1244 /Users/antonio/.zsh_history

The first thing that caught my attention was SAVEHIST=1000. For years I had treated history as if it were a sort of permanent archive of my commands, while my Mac was configured to keep only about a thousand of them in the file. wc -l shows 1,244 physical lines rather than 1,000 because lines and events do not necessarily correspond (a single command can span multiple lines), and because with APPEND_HISTORY the file can grow beyond SAVEHIST before the periodic rewrite. The documentation specifies that trimming occurs when the number of lines exceeds the value of SAVEHIST by about 20%.

For someone who uses Terminal only occasionally, a thousand events can last a long time. I, on the other hand, work extensively in iTerm2 and often keep around ten tabs open at the same time. At that pace, a thousand commands represent a fairly short window. A command run weeks earlier may therefore have disappeared simply because zsh applied exactly the configuration it had been given.

The second factor emerged when I checked the active options:

set -o | grep -Ei \
'appendhistory|incappendhistory|incappendhistorytime|sharehistory|histsavebycopy|extendedhistory'

noappendhistory off
extendedhistory off
nohistsavebycopy off
incappendhistory off
incappendhistorytime off
sharehistory off

The terminology can be confusing because set -o displays some options in their negative form. noappendhistory off means that APPEND_HISTORY is enabled, while nohistsavebycopy off corresponds to HIST_SAVE_BY_COPY being enabled. INC_APPEND_HISTORY, INC_APPEND_HISTORY_TIME, and SHARE_HISTORY, on the other hand, are disabled.

The documentation for [APPEND_HISTORY](https://zsh.sourceforge.io/Doc/Release/Options.html) explains that, with this setting, different zsh sessions append their respective histories to the file in the order in which they terminate. New commands are not necessarily written to disk as soon as they are executed. SHARE_HISTORY, on the other hand, causes a shell to import new commands from the file and causes typed commands to be added to the shared history.

In my everyday use, this matters quite a bit. With around ten iTerm2 tabs open, a command I have just executed in tab A can remain in that shell’s internal history. If I switch to tab B and search for it with **Ctrl+R**, that second shell may not know about it. Even after another session has written new events to the file, a shell that is already open with SHARE_HISTORY disabled does not automatically import those entries.

This is behavior that from the user’s point of view looks very much like lost history: I am sure I typed a command, I press Ctrl+R in a different tab, and it does not appear. The explanation may simply be that I am querying the wrong shell's history.

Get Antonio Troise’s stories in your inbox

Join Medium for free to get updates from this writer.

I was able to verify this directly. The iTerm2 shell I was using had been started on August 16 at 17:37:

ps -o lstart= -p $$

dom 16 ago 17:37:29 2026

The internal history contained the commands I had just executed:

1004 source env/bin/activate
1005 pip install "urllib3<2"
1006 pip install - upgrade pip
1007 pip install -r requirements.txt
1008 ollama pull embeddinggemma
1009 uvicorn app.main:app - reload - host 127.0.0.1 - port 8000
1010 uvicorn app.main:app - reload - host 127.0.0.1 - port 8000
1011 uvicorn app.main:app - reload - host 127.0.0.1 - port 8000
1012 deactivate

but the command

tail -10 ~/.zsh_history

still showed the old commands saved on disk, and the file had last been modified on August 8. The August 16 commands therefore existed in the shell, but had not yet made it into ~/.zsh_history.

This check separated three phenomena that I would previously have perceived in the same way.

  1. A sufficiently old command can disappear because **SAVEHIST=1000** imposes a very short persistent history.
  2. A recent command can appear to be missing because it is in the history of another one of the many iTerm2 shells I keep open and **SHARE_HISTORY** is disabled.
  3. Finally, there is the bug discovered by Stapelberg, which causes actual data loss when an interrupt hits the read operation during a rewrite of the file.

I cannot determine which of these explanations applies to the incidents I remember. The first two are definitely present in my configuration and are enough to explain many cases. The third problem is present in the code I am using, but my **.zsh_history** is only 62 KB, so the window in which **Ctrl+C** would have to interrupt the read at exactly the right moment is much narrower than in Stapelberg's configuration.

How to check your own Mac

To find out whether your Mac is in a similar situation, it is best to start with the shell that is actually running, without assuming that the one included with the system is also the one launched by your terminal:

echo "ZSH_VERSION=$ZSH_VERSION"
echo "SHELL=$SHELL"
echo "TERM_PROGRAM=$TERM_PROGRAM"

command -v zsh
which -a zsh

echo
echo "zsh di macOS:"
/bin/zsh - version

$ZSH_VERSION describes the zsh instance currently running. $SHELL normally indicates the login shell configured for the user, so it is not necessarily proof of which process is handling the current window. command -v zsh instead shows which executable would be found through the PATH, while which -a zsh lets you find out whether multiple versions are installed on the computer.

On an Apple Silicon Mac with zsh installed through Homebrew, for example, we might have:

/opt/homebrew/bin/zsh
/bin/zsh

To compare the versions directly:

bin/zsh --version
"$(brew --prefix)/bin/zsh" --version

The second check concerns the history:

echo "HISTFILE=$HISTFILE"
echo "HISTSIZE=$HISTSIZE"
echo "SAVEHIST=$SAVEHIST"

ls -lh "$HISTFILE"
wc -l "$HISTFILE"

set -o | grep -Ei \
'appendhistory|incappendhistory|incappendhistorytime|sharehistory|histsavebycopy|extendedhistory'

If you use Terminal.app and $HISTFILE points to ~/.zsh_sessions/...historynew, you have not necessarily found an anomaly: that is Apple's Terminal session management system. In iTerm2, unless you have a custom configuration, it is more common to find ~/.zsh_history directly.

To compare what the shell knows with what has already reached the disk, you can then use:

echo "history in memory:"
fc -l -20

echo
echo "history on disk:"
tail -20 "$HISTFILE"

If fc shows commands that were just executed while tail stops much earlier, the explanation may simply be the way your configuration writes history.

To find out how long that specific shell has been open:

ps -o lstart= -p $$

This also becomes useful when working with many tabs that remain open for days. An old shell can have its own internal history that differs from that of a newly created tab.

Unfortunately, there is no specific diagnostic command to determine whether the file has suddenly shrunk. We can check its size and number of lines:

ls -lh ~/.zsh_history
wc -l ~/.zsh_history

but the best comparison remains an earlier backup, for example Time Machine. If a file that normally occupied several megabytes suddenly drops to a fraction of its size without any changes to **SAVEHIST** or the other parameters, it is worth investigating. After the bug occurs, the file can be perfectly valid, so the absence of visible corruption does not prove that the history has not been truncated.

I would avoid trying to intentionally reproduce the problem on your own .zsh_history by rapidly pressing Ctrl+D and Ctrl+C. The desired result of the reproducer is precisely the loss of part of the history. If you want to experiment with zsh's behavior, it is better to do so with a separate shell and a temporary HISTFILE.

The bug has been fixed, and what I will change on my Mac

In the official version of zsh, the problem is fixed in zsh 5.9.2, released on July 12, 2026. The patch is the commit “53454: fix interrupt handling in savehistfile()”, prepared in April 2025. As of August 16, 2026, the Homebrew formula for zsh distributes 5.9.2 as the stable version.

Homebrew users can therefore install it with:

brew install zsh

or update it with:

brew upgrade zsh

and check the installed executable with:

"$(brew --prefix)/bin/zsh" --version

which should return version 5.9.2. The installation does not automatically change the Mac’s login shell. Before using the Homebrew version as the default shell, it is best to check its path, add it to /etc/shells if necessary, and configure it with chsh only after deciding to replace /bin/zsh.

As for Apple, the README of the open source repository available at the time of writing still identifies the version as zsh 5.9. The decisive evidence remains [hist.c](https://raw.githubusercontent.com/apple-oss-distributions/zsh/refs/heads/main/zsh/Src/hist.c), which still lacks the ERRFLAG_INT check introduced by the patch. Based on that source code, I therefore consider the Apple zsh I am using to be affected by the problem. Checking the version number alone remains insufficient because a distributor can always backport a fix without changing the declared version.

Separately from the bug, my history configuration needs to be revised. With around ten iTerm2 tabs routinely open, **SAVEHIST=1000** is too low for the way I use Terminal. A more suitable configuration might be, for example:

HISTFILE=$HOME/.zsh_history
HISTSIZE=100000
SAVEHIST=100000

setopt APPEND_HISTORY
setopt SHARE_HISTORY
setopt HIST_SAVE_BY_COPY
setopt HIST_IGNORE_DUPS
setopt HIST_IGNORE_SPACE
setopt EXTENDED_HISTORY

Before changing it, it is of course a good idea to preserve the existing history:

cp -p ~/.zsh_history ~/.zsh_history.backup

SAVEHIST=100000 makes it possible to keep a much longer archive. SHARE_HISTORY instead changes the behavior of the many tabs open at the same time: according to the zsh documentation, this option imports new commands from the shared file and also causes typed commands to be written to it, while using the extended history timestamp format. The choice has some consequences for how histories from different sessions are merged, so I prefer to regard it as an explicit choice rather than a mandatory fix. For the way I work, however, being able to search from tab B for a command I just executed in tab A is probably more useful than keeping the individual shells isolated.

None of these settings fixes the truncation bug. Increasing **SAVEHIST**, enabling **SHARE_HISTORY**, or changing the way tabs see each other's commands serves to improve the everyday behavior of history; fixing the bug requires the code introduced upstream and included in zsh 5.9.2.

Conclusion

I started from Stapelberg’s article thinking that I might have found the culprit behind those commands I had been convinced I had lost over the years. Checking my Mac confirmed that I am using Apple’s zsh 5.9 and that Apple’s publicly available source code still contains the code path involved. It also showed, however, that I was keeping only about a thousand events and that the many iTerm2 tabs I work with every day do not share their history. These are two very concrete explanations for much of the behavior I had always attributed to the other computer, the wrong shell, or my memory.

I will probably never know whether the bug has truncated my .zsh_history at least once. But now I know that it can, I know why it happens, and, above all, I know that the file I had treated for years as an almost permanent archive was configured very differently from what I imagined. For a hidden 62 KB file, it turned out to be a surprisingly instructive investigation.

Originally published at Levysoft.

--

--

Antonio Troise
Antonio Troise

Written by Antonio Troise

Blogger at levysoft.it and english edition curator on Medium in AI tech. Former founder of Gamertagmatch and Seguiprezzi. Sharing on levysoft.tumblr.com.