Skip to content

PS1 belongs to bash

Level: 101 · you are reading a Linux book on a Mac, and it has just told you to type export PS1="[\d \t \u@\h \w]\$ "

One line: The prompt is drawn by the shell, not by the terminal, so the book's PS1 line is an instruction to bash. bash draws it the same way on Linux and on a Mac. zsh reads the same variable but prints the backslash escapes as they are, because its own escapes start with %. fish never reads PS1 at all: a fish prompt is whatever a function named fish_prompt prints.

Is it valid in fish? No, and nothing tells you so. fish runs the book's export PS1=… line without an error, stores the variable, and goes on drawing the same prompt. To change a fish prompt you define the function fish_prompt, and to keep it you run funcsave fish_prompt, which writes the function to ~/.config/fish/functions/fish_prompt.fish. The book's first prompt, in fish:

function fish_prompt
    echo -n "[$USER@"(prompt_hostname)" "(path basename (prompt_pwd -d 0))"]\$ "
end
funcsave fish_prompt

The fish run under In zsh and fish shows both halves: the PS1 line changing nothing, and this function drawing the book's prompt.

Measured

The book's page is about bash on Fedora. Its prompt, [donnie@fedora ~]$, comes from the variable PS1, set to [\u@\h \W]\$: the user name, the host name up to its first dot, the last part of the working directory, and $ (# for root). Changing it to [\d \t \u@\h \w]\$ adds the date, the time and the whole path. A prompt set at the command line lasts until that shell ends, and the book keeps it by adding the export line to ~/.bashrc.

A prompt is drawn only when a shell is waiting for someone to type, and CI has nobody at a keyboard, so the script types instead. It pipes lines into bash --norc --noediting -i. -i makes bash interactive although its input is a pipe, so it prints a prompt before it reads each line; --norc keeps ~/.bashrc out; --noediting turns off readline, which would echo each line a second time. Each line prints itself just before it runs, standing in for the terminal showing what you type, and bash prints exit on its own. Every session starts at the prompt $, in a fresh empty HOME that holds one folder, projects/linux-library.

Your user name, host name and clock are not CI's, so the script rewrites them after bash has drawn each prompt: what id -un prints becomes USER, the host name up to its first dot becomes HOST, a date such as Mon Sep 14 becomes Www Mmm dd, and a time becomes HH:MM:SS. Every other character is bash's.

Verified output of ps1_prompts_sh.sh, identical on Linux and macOS — regenerated by tools/run_examples.py, never hand-typed.

$ export PS1="[\u@\h \W]\$ "
[USER@HOST ~]$ echo "$PS1"
[\u@\h \W]$ 
[USER@HOST ~]$ cd projects/linux-library
[USER@HOST linux-library]$ cd /usr/bin
[USER@HOST bin]$ cd /usr
[USER@HOST usr]$ exit

$ export PS1="[\d \t \u@\h \w]\$ "
[Www Mmm dd HH:MM:SS USER@HOST ~]$ cd projects/linux-library
[Www Mmm dd HH:MM:SS USER@HOST ~/projects/linux-library]$ cd /usr/bin
[Www Mmm dd HH:MM:SS USER@HOST /usr/bin]$ exit

$ printf 'echo only this reached stdout\n' | PS1='[\u@\h \W]\$ ' bash --norc --noediting -i 2>/dev/null
only this reached stdout

$ cat ~/.bashrc
export PS1="[\d \t \u@\h \w]\$ "

$ bash
[Www Mmm dd HH:MM:SS USER@HOST ~]$ cd projects/linux-library
[Www Mmm dd HH:MM:SS USER@HOST ~/projects/linux-library]$ exit
  • \W is the last part of the path: linux-library, bin, usr. In the home folder it is ~, not the folder's name.
  • \w is the whole path, with the home folder written as ~.
  • \d and \t are the weekday, month and day (Www Mmm dd) and the 24-hour time (HH:MM:SS).
  • The book's double quotes eat a backslash. echo "$PS1" shows [\u@\h \W]$: inside double quotes \$ is an escaped dollar sign, so the shell turned it into a plain $ before PS1 was set. The prompt still ends in $, but bash's \$ escape, the one the bash manual ↗ says turns into # for root, never reaches it. Single quotes, PS1='[\u@\h \W]\$ ', keep it.
  • The prompt goes to stderr. With 2>/dev/null, the prompts and the exit are gone, and the command's own output is still there.
  • A prompt lives in the shell that set it. Each session starts at $ again, although the session before it exported the book's prompt. The last one starts with the book's prompt because by then the line was in ~/.bashrc, and a new bash reads that file when it starts.

On a Mac

The key above is shared: macOS's /bin/bash 3.2 and Ubuntu's bash 5.2 drew every prompt byte for byte the same, so every escape on the book's page works on a Mac. What differs is around the prompt, and this run is recorded on each machine separately:

Verified output of ps1_on_a_mac_sh.sh on Linux — regenerated by tools/run_examples.py, never hand-typed.

$ bash -c 'PS1="[\u@\h \W]\$ "; echo "${PS1@P}"'
[USER@HOST ~]$ 
(exit status 0)

$ cat ~/.bashrc
export PS1="[\d \t \u@\h \w]\$ "

$ bash -l
USER@HOST:~$ echo "$PS1"
${debian_chroot:+($debian_chroot)}\u@\h:\w\$ 
USER@HOST:~$ logout

$ cat ~/.bash_profile
[ -r ~/.bashrc ] && . ~/.bashrc

$ bash -l
[Www Mmm dd HH:MM:SS USER@HOST ~]$ cd projects/linux-library
[Www Mmm dd HH:MM:SS USER@HOST ~/projects/linux-library]$ logout

$ bash
$ echo "$BASH_VERSINFO"
5
$ exit

Verified output of ps1_on_a_mac_sh.sh on macOS — regenerated by tools/run_examples.py, never hand-typed.

$ bash -c 'PS1="[\u@\h \W]\$ "; echo "${PS1@P}"'
bash: ${PS1@P}: bad substitution
(exit status 1)

$ cat ~/.bashrc
export PS1="[\d \t \u@\h \w]\$ "

$ bash -l
HOST:~ USER$ echo "$PS1"
\h:\W \u\$ 
HOST:~ USER$ logout

$ cat ~/.bash_profile
[ -r ~/.bashrc ] && . ~/.bashrc

$ bash -l
[Www Mmm dd HH:MM:SS USER@HOST ~]$ cd projects/linux-library
[Www Mmm dd HH:MM:SS USER@HOST ~/projects/linux-library]$ logout

$ bash

The default interactive shell is now zsh.
To update your account to use zsh, please run `chsh -s /bin/zsh`.
For more details, please visit https://support.apple.com/kb/HT208050.
$ echo "$BASH_VERSINFO"
3
$ exit
  • ${PS1@P} needs a newer bash than the Mac's. It expands a prompt string without drawing it, the quickest way to try one. bash 5.2 printed the prompt; bash 3.2 calls it a bad substitution and exits 1. On a Mac, try a prompt by setting it, or with zsh's print -P, below.
  • A login shell does not read ~/.bashrc. With the book's line in ~/.bashrc, bash -l started with a different prompt on each machine, and with the book's on neither. echo "$PS1" shows where each came from: \h:\W \u\$ is the prompt macOS's /etc/bashrc sets, and \u@\h:\w\$ behind a debian_chroot test is the one Ubuntu's /etc/bash.bashrc sets. Which startup file runs measures which of your own files each kind of bash reads.
  • Terminal.app opens a login shell. That is documented behaviour, not something CI measures, since CI has no Terminal windows: About bash_profile and bashrc on macOS ↗ explains that a new Terminal window runs .bash_profile, where a Linux terminal runs .bashrc. On the Mac this page was written on (macOS 26.6, checked with ps on 2026-09-13), Terminal's child process was login -pf with the user's name, and its child was -fish: a shell whose name starts with - is a login shell (bash manual, Invoking Bash ↗). So on a Mac, the book's ~/.bashrc line is skipped by exactly the shell it was written for.
  • The fix is one line in ~/.bash_profile: [ -r ~/.bashrc ] && . ~/.bashrc. With it, the login shell drew the book's prompt on both machines.
  • And bash is not the Mac's shell. An interactive /bin/bash on macOS first prints a notice that the default interactive shell is now zsh, and its $BASH_VERSINFO is 3. (The other runs on this page set BASH_SILENCE_DEPRECATION_WARNING to hide that notice.) Apple's Terminal guide ↗ gives zsh as the default login shell, so a Mac reader following the book is probably typing into zsh.

In zsh and fish

zsh reads PS1 too, and PROMPT is another name for the same variable. The book's line is not an error, but zsh's prompt escapes start with %. The zsh sessions start at the prompt %:

Verified output of ps1_prompts_zsh.zsh, identical on Linux and macOS — regenerated by tools/run_examples.py, never hand-typed.

% export PS1="[\u@\h \W]\$ "
[\u@\h \W]$ cd projects/linux-library
[\u@\h \W]$ cd /usr/bin
[\u@\h \W]$ exit

% PROMPT="[%n@%m %1~]%# "
[USER@HOST ~]% cd projects/linux-library
[USER@HOST linux-library]% cd /usr/bin
[USER@HOST bin]% cd /usr
[USER@HOST /usr]% print -r -- "$PS1"
[%n@%m %1~]%# 
[USER@HOST /usr]% exit

% PROMPT="[%D{%a %b %d} %D{%H:%M:%S} %n@%m %~]%# "
[Www Mmm dd HH:MM:SS USER@HOST ~]% cd projects/linux-library
[Www Mmm dd HH:MM:SS USER@HOST ~/projects/linux-library]% cd /usr/bin
[Www Mmm dd HH:MM:SS USER@HOST /usr/bin]% exit

$ print -rP -- '[%n@%m %1~]%# '
[USER@HOST ~]% 

$ printf 'echo only this reached stdout\n' | PS1='[%n@%m %1~]%# ' zsh -f -i 2>/dev/null
only this reached stdout

$ cat ~/.zshrc
PROMPT="[%D{%a %b %d} %D{%H:%M:%S} %n@%m %~]%# "

$ zsh -d
[Www Mmm dd HH:MM:SS USER@HOST ~]% cd projects/linux-library
[Www Mmm dd HH:MM:SS USER@HOST ~/projects/linux-library]% exit
  • The backslash escapes are printed as they are. [\u@\h \W]$ is exactly what was stored, and nothing warns you.
  • The zsh spelling of the book's first prompt is [%n@%m %1~]%#: %n is the user, %m the host up to its first dot, %1~ the last part of the path, and %# a % (the zsh manual ↗ gives # for root). print -r -- "$PS1" after setting PROMPT shows that the two names are one variable.
  • %1~ is not quite \W. In /usr it printed /usr, where bash printed usr. In /usr/bin both printed bin.
  • The date and time are %D{…} with strftime letters inside: %D{%a %b %d} for \d, %D{%H:%M:%S} for \t.
  • print -P tries a prompt without starting a shell. It works in the zsh a Mac ships, where bash's ${PS1@P} does not.
  • The prompt goes to stderr in zsh as well.
  • ~/.zshrc keeps it. A new zsh read the file and started with the prompt. The -d only skips the system-wide startup files, which have nothing to do with this prompt: on GitHub's Ubuntu runner /etc/zsh/zshrc runs compinit, and that aborts with "not interactive and can't open terminal" when zsh reads its commands from a pipe. A login zsh reads ~/.zshrc too, so in zsh the book's advice works on a Mac once .bashrc is read as .zshrc.

fish has no PS1. Before each command line it calls the function fish_prompt and shows what it printed, so the script calls fish_prompt directly instead of typing into a fish. It runs in a fresh fish --no-config with a throwaway HOME, because funcsave writes into your configuration:

Verified output of ps1_prompts_fish.fish, identical on Linux and macOS — regenerated by tools/run_examples.py, never hand-typed.

$ functions fish_prompt | head -n 2
# Defined in embedded:functions/fish_prompt.fish @ line 4
function fish_prompt --description 'Write out the prompt'

$ fish_prompt; echo
USER@HOST ~> 

$ export PS1="[\u@\h \W]\$ "; fish_prompt; echo
USER@HOST ~> 

$ echo $PS1
[\u@\h \W]$ 

$ cd projects/linux-library; fish_prompt; echo
USER@HOST ~/p/linux-library> 

$ function fish_prompt
    echo -n "[$USER@"(prompt_hostname)" "(path basename (prompt_pwd -d 0))"]\$ "
end

$ for dir in ~ ~/projects/linux-library /usr/bin /usr; cd $dir; fish_prompt; echo; end
[USER@HOST ~]$ 
[USER@HOST linux-library]$ 
[USER@HOST bin]$ 
[USER@HOST usr]$ 

$ fish_is_root_user; and echo "#"; or echo "\$"
$

$ function fish_prompt
    echo -n "["(date "+%a %b %d %H:%M:%S")" $USER@"(prompt_hostname)" "(prompt_pwd -d 0)"]\$ "
end

$ for dir in ~ ~/projects/linux-library /usr/bin; cd $dir; fish_prompt; echo; end
[Www Mmm dd HH:MM:SS USER@HOST ~]$ 
[Www Mmm dd HH:MM:SS USER@HOST ~/projects/linux-library]$ 
[Www Mmm dd HH:MM:SS USER@HOST /usr/bin]$ 

$ funcsave fish_prompt
funcsave: wrote ~/.config/fish/functions/fish_prompt.fish

$ cat ~/.config/fish/functions/fish_prompt.fish
function fish_prompt
    echo -n "["(date "+%a %b %d %H:%M:%S")" $USER@"(prompt_hostname)" "(prompt_pwd -d 0)"]\$ "
end

$ fish -c fish_prompt; echo
[Www Mmm dd HH:MM:SS USER@HOST ~]$ 

$ fish --no-config -c fish_prompt; echo
USER@HOST ~> 
  • fish_prompt is a function, and fish ships one: functions fish_prompt shows its definition, starting with where it came from.
  • The book's line changes nothing. export PS1=… ran without complaint, and echo $PS1 shows the value (fish's double quotes also turned \$ into $), but fish_prompt printed the same USER@HOST ~> before and after.
  • The fish version is a function. $USER stands for \u and prompt_hostname for \h. path basename (prompt_pwd -d 0) is \W, ~ at home and usr in /usr included, which zsh's %1~ gets wrong. prompt_pwd -d 0 is \w: plain prompt_pwd, as in the default prompt, shortens each folder to one letter, ~/p/linux-library. The date and time come from date.
  • fish_is_root_user is the test behind bash's \$. It failed here, so the line printed $.
  • funcsave fish_prompt wrote ~/.config/fish/functions/fish_prompt.fish. A new fish loaded the prompt from that file with no config.fish at all, and fish --no-config, which skips your configuration, drew the default prompt again.
  • fish also ships ready-made prompts. On the Mac this page was written on, fish_config prompt list named thirteen of them, fish_config prompt choose NAME switched the running fish to one, and fish_config prompt save asked before overwriting fish_prompt.fish (with no answer it printed Not overwriting). That was fish 4.3.2 on 2026-09-13, and it is not in the answer key.

The same prompt in the three shells, every cell taken from the runs above:

bash PS1 zsh PROMPT fish fish_prompt
\u %n $USER
\h %m prompt_hostname
\W %1~ (but /usr, not usr) path basename (prompt_pwd -d 0)
\w %~ prompt_pwd -d 0
\d %D{%a %b %d} date "+%a %b %d"
\t %D{%H:%M:%S} date +%H:%M:%S
\$ %# fish_is_root_user

And where each one is kept: bash's in ~/.bashrc, which a login bash reads only if ~/.bash_profile tells it to; zsh's in ~/.zshrc; fish's in ~/.config/fish/functions/fish_prompt.fish, written by funcsave fish_prompt.

If you are coming from another library

  • Python. -c is not the prompt ↗ pipes a line into python3 -i and finds its >>> prompts on stderr, the same trick this page plays on bash and zsh.
  • Encodings. A pipe is not a terminal ↗ covers what changes for a program when the other end of a descriptor is a pipe instead of a terminal. An interactive bash reading a pipe is one such program: it prints its prompts, and says it has no job control.

See also