I'm running shell scripts from Jenkins, which kicks off shell scripts with the shebang options #!/bin/sh -ex.

According to Bash Shebang for dummies?, -x, "causes the shell to print an execution trace", which is great for most purposes - except for echos:

echo "Message"

produces the output

+ echo "Message"

which is a bit redundant, and looks a bit strange. Is there a way to leave -x enabled, but only output


instead of the two lines above, e.g. by prefixing the echo command with a special command character, or redirecting output?

10 Answers 10


When you are up to your neck in alligators, it’s easy to forget that the goal was to drain the swamp.                   — popular saying

The question is about echo, and yet the majority of the answers so far have focused on how to sneak a set +x command in.  There’s a much simpler, more direct solution:

{ echo "Message"; } 2> /dev/null

(I acknowledge that I might not have thought of the { …; } 2> /dev/null if I hadn’t seen it in the earlier answers.)

This is somewhat cumbersome, but, if you have a block of consecutive echo commands, you don’t need to do it on each one individually:

  echo "The quick brown fox"
  echo "jumps over the lazy dog."
} 2> /dev/null

Note that you don’t need semicolons when you have newlines.

You can reduce the typing burden by using kenorb’s idea of opening /dev/null permanently on a non-standard file descriptor (e.g., 3) and then saying 2>&3 instead of 2> /dev/null all the time.

The first four answers at the time of this writing require doing something special (and, in most cases, cumbersome) every time you do an echo.  If you really want all echo commands to suppress the execution trace (and why wouldn’t you?), you can do so globally, without munging a lot of code.  First, I noticed that aliases aren’t traced:

$ myfunc()
> {
>     date
> }
$ alias myalias="date"
$ set -x
$ date
+ date
Mon, Oct 31, 2016  0:00:00 AM           # Happy Halloween!
$ myfunc
+ myfunc                                # Note that function call is traced.
+ date
Mon, Oct 31, 2016  0:00:01 AM
$ myalias
+ date                                  # Note that it doesn’t say  + myalias
Mon, Oct 31, 2016  0:00:02 AM

(Note that the following script snippets work if the shebang is #!/bin/sh, even if /bin/sh is a link to bash.  But, if the shebang is #!/bin/bash, you need to add a shopt -s expand_aliases command to get aliases to work in a script.)

So, for my first trick:

alias echo='{ set +x; } 2> /dev/null; builtin echo'

Now, when we say echo "Message", we’re calling the alias, which doesn’t get traced.  The alias turns off the trace option, while suppressing the trace message from the set command (using the technique presented first in user5071535’s answer), and then executes the actual echo command.  This lets us get an effect similar to that of user5071535’s answer without needing to edit the code at every echo command.  However, this leaves trace mode turned off.  We can’t put a set -x into the alias (or at least not easily) because an alias only allows a string to be substituted for a word; no part of the alias string can be injected into the command after the arguments (e.g., "Message").  So, for example, if the script contains

echo "The quick brown fox"
echo "jumps over the lazy dog."

the output would be

+ date
Mon, Oct 31, 2016  0:00:03 AM
The quick brown fox
jumps over the lazy dog.
Mon, Oct 31, 2016  0:00:04 AM           # Note that it doesn’t say  + date

so you still need to turn the trace option back on after displaying message(s) — but only once after every block of consecutive echo commands:

echo "The quick brown fox"
echo "jumps over the lazy dog."
set -x

It would be nice if we could make the set -x automatic after an echo — and we can, with a bit more trickery.  But before I present that, consider this.  The OP is starting with scripts that use a #!/bin/sh -ex shebang.  Implicitly the user could remove the x from the shebang and have a script that works normally, without execution tracing.  It would be nice if we could develop a solution that retains that property.  The first few answers here fail that property because they turn tracing “back” on after echo statements, unconditionally, without regard to whether it was already on.  This answer conspicuously fails to recognize that issue, as it replaces echo output with trace output; therefore, all the messages vanish if tracing is turned off.  I will now present a solution that turns tracing back on after an echo statement conditionally — only if it was already on.  Downgrading this to a solution that turns tracing “back” on unconditionally is trivial and is left as an exercise.

alias echo='{ save_flags="$-"; set +x;} 2> /dev/null; echo_and_restore'
echo_and_restore() {
        builtin echo "$*"
        case "$save_flags" in
         (*x*)  set -x

$- is the options list; a concatenation of the letters corresponding to all the options that are set.  For example, if the e and x options are set, then $- will be a jumble of letters that includes e and x.  My new alias (above) saves the value of $- before turning tracing off.  Then, with tracing turned off, it throws control over into a shell function.  That function does the actual echo and then checks to see whether the x option was turned on when the alias was invoked.  If the option was on, the function turns it back on; if it was off, the function leaves it off.

You can insert the above seven lines (eight, if you include an shopt) at the beginning of the script and leave the rest alone.

This would allow you

  1. to use any of the following shebang lines:
    #!/bin/sh -ex
    #!/bin/sh -e
    #!/bin/sh –x
    or just plain
    and it should work as expected.
  2. to have code like
    set -x
    set +x
    • Commands 4, 5, and 6 will be traced — unless one of them is an echo, in which case it will be executed but not traced.  (But even if command 5 is an echo, command 6 still will be traced.)
    • Commands 7, 8, and 9 will not be traced.  Even if command 8 is an echo, command 9 still will not be traced.
    • Commands 1, 2, and 3 will be traced (like 4, 5, and 6) or not (like 7, 8, and 9) depending on whether the shebang includes x.

P.S. I have discovered that, on my system, I can leave out the builtin keyword in my middle answer (the one that’s just an alias for echo).  This is not surprising; bash(1) says that, during alias expansion, …

… a word that is identical to an alias being expanded is not expanded a second time.  This means that one may alias ls to ls -F, for instance, and bash does not try to recursively expand the replacement text.

Not too surprisingly, the last answer (the one with echo_and_restore) fails if the builtin keyword is omitted1.  But, oddly it works if I delete the builtin and switch the order:

echo_and_restore() {
        echo "$*"
        case "$save_flags" in
         (*x*)  set -x
alias echo='{ save_flags="$-"; set +x;} 2> /dev/null; echo_and_restore'

1 It seems to give rise to undefined behavior.  I’ve seen

  • an infinite loop (probably because of unbounded recursion),
  • a /dev/null: Bad address error message, and
  • a core dump.
  • 2
    I have seen some amazing magic tricks done with aliases, so I know my knowledge thereof is incomplete. If anybody can present a way to do the equivalent of echo +x; echo "$*"; echo -x in an alias, I’d like to see it. Nov 1, 2016 at 3:20
  • I wonder what is the difference between { echo foo; } 2> /dev/null and (echo foo) 2> /dev/null. Both work for me and the latter looks a bit more straightforward... Mar 4, 2020 at 17:21
  • 1
    @G-ManSays'ReinstateMonica' Yep, thanks for clarification. Yep, I read that thread about (exit 1) and understand the difference, the fact that () is forking subshells and etc. My case is pretty simple (just some config tooling), and I rather interested in readability (especially for people not experienced in shells)/correctness in terms of exit statuses and etc rather than performance. So leaving performance aside, it looks like for echo it's quite ok to use () in my case. Mar 5, 2020 at 9:59
  • 1
    @BryanRoach: (1) I covered that already: “Note that the following script snippets work if the shebang is #!/bin/sh, even if /bin/sh is a link to bash. But, if the shebang is #!/bin/bash, you need to add a shopt -s expand_aliases command to get aliases to work in a script.” (2) Please learn how quoting works. Your code doesn’t work properly for printf, and it has the same problem with echo that my answer has (identified by MoonLite). Jun 29, 2022 at 17:17
  • 1
    @G-ManSays'ReinstateMonica' (1) Oh, sorry! I should have checked more carefully. (2) My code works for the simple case of passing one string to print, but we can support all cases. None of the answers have gotten the quoting right. Instead of "$*" or $* it should be "$@". Jun 29, 2022 at 23:20

I found a partial solution over at InformIT:

#!/bin/bash -ex
set +x; 
echo "shell tracing is disabled here"; set -x;
echo "but is enabled here"


set +x; 
shell tracing is disabled here 
+ echo "but is enabled here"
but is enabled here

Unfortunately, that still echoes set +x, but at least it's quiet after that. so it's at least a partial solution to the problem.

But is there maybe a better way to do this? :)


This way improves upon your own solution by getting rid of the set +x output:

#!/bin/bash -ex
{ set +x; } 2>/dev/null
echo "shell tracing is disabled here"; set -x;
echo "but is enabled here"

I love the comprehensive and well explained answer by g-man, and consider it the best one provided so far. It cares about the context of the script, and doesn't force configurations when they aren't needed. So, if you're reading this answer first go ahead and check that one, all the merit is there.

However, in that answer there is an important piece missing: the proposed method won't work for a typical use case, i.e. reporting errors:

COMMAND || echo "Command failed!"

Due to how the alias is constructed, this will expand to

COMMAND || { save_flags="$-"; set +x; } 2>/dev/null; echo_and_restore "Command failed!"

and you guessed it, echo_and_restore gets executed always, unconditionally. Given that the set +x part didn't run, it means that the contents of that function will get printed, too.

Changing the last ; to && wouldn't work either, because in Bash, || and && are left-associative.

I found a modification which works for this use case:

echo_and_restore() {
    cat -
    case "$save_flags" in
        (*x*) set -x
alias echo='({ save_flags="$-"; set +x; } 2>/dev/null; echo_and_restore) <<<'

It uses a subshell (the (...) part) in order to group all commands, and then passes the input string through stdin as a Here String (the <<< thing) which is then printed by cat -. The - is optional, but you know, "explicit is better than implicit".

The cat - can be changed to personalize the output. For example, to prepend the name of the currently running script, you could change the function to something like this:

echo_and_restore() {
    local BASENAME; BASENAME="$(basename "$0")" # File name of the current script.
    echo "[$BASENAME] $(cat -)"
    case "$save_flags" in
        (*x*) set -x

And now it works beautifully:

false || echo "Command failed"
> [test.sh] Command failed

Put set +x inside the brackets, so it would apply for local scope only.

For example:

#!/bin/bash -x
exec 3<> /dev/null
(echo foo1 $(set +x)) 2>&3
($(set +x) echo foo2) 2>&3
( set +x; echo foo3 ) 2>&3

would output:

$ ./foo.sh 
+ exec
+ true
  • 1
    Correct me if I'm wrong, but I don't think the set +x inside the subshell (or using subshells at all) is doing anything useful. You can remove it and get the same result. It's the redirecting stderr to /dev/null that is doing the work of temporarily "disabling" tracing... It seems echo foo1 2>/dev/null, etc., would be just as effective, and more readable.
    – Tyler Rick
    Dec 10, 2018 at 19:33
  • Having tracing in your script could impact performance. Secondly redirecting &2 to NULL could be not the same, when you expect some other errors.
    – kenorb
    Dec 10, 2018 at 22:15
  • 1
    Correct me if I'm wrong, but in your example you already have tracing enabled in your script (with bash -x) and you are already redirecting &2 to null (since &3 was redirected to null), so I'm not sure how that comment is relevant. Maybe we just need a better example that illustrates your point, but in the given example at least, it still seems like it could be simplified without losing any benefit.
    – Tyler Rick
    Dec 12, 2018 at 0:43

Execution trace goes to stderr, filter it this way:

./script.sh 2> >(grep -v "^+ echo " >&2)

Some explanation, step by step:

  • stderr is redirected… – 2>
  • …to a command. – >(…)
  • grep is the command…
  • …which requires beginning of the line… – ^
  • …to be followed by + echo
  • …then grep inverts the match… – -v
  • …and that discards all the lines you don't want.
  • The result would normally go to stdout; we redirect it to stderr where it belongs. – >&2

The problem is (I guess) this solution may desynchronize the streams. Because of filtering stderr may be a little late in relation to stdout (where echo output belongs by default). To fix it you can join the streams first if you don't mind having them both in stdout:

./script.sh > >(grep -v "^+ echo ") 2>&1

You can build such a filtering into the script itself but this approach is prone to desynchronization for sure (i.e. it has occurred in my tests: execution trace of a command might appear after the output of immediately following echo).

The code looks like this:

#!/bin/bash -x

 # original script here
 # …
} 2> >(grep -v "^+ echo " >&2)

Run it without any tricks:


Again, use > >(grep -v "^+ echo ") 2>&1 to maintain the synchronization at the cost of joining the streams.

Another approach. You get "a bit redundant" and strange-looking output because your terminal mixes stdout and stderr. These two streams are different animals for a reason. Check if analyzing stderr only fits your needs; discard stdout:

./script.sh > /dev/null

If you have in your script an echo printing debug/error message to stderr then you may get rid of redundancy in a way described above. Full command:

./script.sh > /dev/null 2> >(grep -v "^+ echo " >&2)

This time we are working with stderr only, so desynchronization is no longer a concern. Unfortunately this way you won't see a trace nor output of echo that prints to stdout (if any). We could try to rebuild our filter to detect redirection (>&2) but if you look at echo foobar >&2, echo >&2 foobar and echo "foobar >&2" then you will probably agree that things get complicated.

A lot depends on echos you have in your script(s). Think twice before you implement some complex filter, it may backfire. It's better to have a bit of redundancy than to accidentally miss some crucial information.

Instead of discarding execution trace of an echo we can discard its output – and any output except the traces. To analyze execution traces only, try:

./script.sh > /dev/null 2> >(grep "^+ " >&2)

Foolproof? No. Think what will happen if there's echo "+ rm -rf --no-preserve-root /" >&2 in the script. Somebody might get heart attack.

And finally…

Fortunately there is BASH_XTRACEFD environmental variable. From man bash:

If set to an integer corresponding to a valid file descriptor, bash will write the trace output generated when set -x is enabled to that file descriptor.

We can use it like this:

(exec 3>trace.txt; BASH_XTRACEFD=3 ./script.sh)
less trace.txt

Note the first line spawns a subshell. This way the file descriptor won't stay valid nor the variable assigned in the current shell afterwards.

Thanks to BASH_XTRACEFD you can analyze traces free of echos and any other outputs, whatever they may be. It's not exactly what you wanted but my analysis makes me think this is (in general) The Right Way.

Of course you can use another method, especially when you need to analyze stdout and/or stderr along with your traces. You just need to remember there are certain limitations and pitfalls. I tried to show (some of) them.


if you do not neet to use the -x option, trap does the job

As pointed out here, trap "${CMD}" DEBUG executes CMD after every command. Use this to print the output instead of set -x.



# https://stackoverflow.com/a/33412142/7128154
trap '[[ $BASH_COMMAND != echo* ]] && echo $BASH_COMMAND' DEBUG

echo "double echo"
VAR="hello world"
echo "${VAR}"


$ bash test2.sh 
double echo
VAR="hello world"
hello world
log.txt  test.sh  test2.sh

Editied 29 Oct 2016 per moderator suggestions that the original did not contain enough information to understand what is happening.

This "trick" produces just one line of message output at the terminal when xtrace is active:

The original question is Is there a way to leave -x enabled, but only output Message instead of the two lines. This is an exact quote from the question.

I understand the question to be, how to "leave set -x enabled AND produce a single line for a message"?

  • In the global sense, this question is basically about aesthetics -- the questioner wants to produce a single line instead of the two virtually duplicate lines produced while xtrace is active.

So in summary, the OP requires:

  1. To have set -x in effect
  2. Produce a message, human readable
  3. Produce only a single line of message output.

The OP does not require that the echo command be used. They cited it as an example of message production, using the abbreviation e.g. which stands for Latin exempli gratia, or "for example".

Thinking "outside the box" and abandoning the use of echo to produce messages, I note that an assignment statement can fulfill all requirements.

Do an assignment of a text string (containing the message) to an inactive variable.

Adding this line to a script doesn't alter any logic, yet produces a single line when tracing is active: (If you are using the variable $echo, just change the name to another unused variable)

echo="====================== Divider line ================="

What you will see on the terminal is only 1 line:

++ echo='====================== Divider line ================='

not two, as the OP dislikes:

+ echo '====================== Divider line ================='
====================== Divider line =================

Here is sample script to demonstrate. Note that variable substitution into a message (the $HOME directory name 4 lines from the end) works, so you can trace variables using this method.

#!/bin/bash -exu
#  Example Script showing how, with trace active, messages can be produced
#  without producing two virtually duplicate line on the terminal as echo does.
dummy="====================== Entering Test Script ================="
if [[ $PWD == $HOME ]];  then
  dummy="*** "
  dummy="*** Working in home directory!"
  dummy="*** "
  ls -la *.c || :
  dummy="---- C Files in current directory"
  ls -la *.c || :
  dummy="----. C Files in Home directory "$HOME
  ls -la  $HOME/*.c || :

And here is the output from running it in the root, then the home directory.

$ cd /&&DemoScript
+ dummy='====================== Entering Test Script ================='
+ [[ / == /g/GNU-GCC/home/user ]]
+ dummy='---- C Files in current directory'
+ ls -la '*.c'
ls: *.c: No such file or directory
+ :
+ dummy='----. C Files in Home directory /g/GNU-GCC/home/user'
+ ls -la /g/GNU-GCC/home/user/HelloWorld.c /g/GNU-GCC/home/user/hw.c
-rw-r--r-- 1 user Administrators 73 Oct 10 22:21 /g/GNU-GCC/home/user/HelloWorld.c
-rw-r--r-- 1 user Administrators 73 Oct 10 22:21 /g/GNU-GCC/home/user/hw.c
+ dummy=---------------------------------

$ cd ~&&DemoScript
+ dummy='====================== Entering Test Script ================='
+ [[ /g/GNU-GCC/home/user == /g/GNU-GCC/home/user ]]
+ dummy='*** '
+ dummy='*** Working in home directory!'
+ dummy='*** '
+ ls -la HelloWorld.c hw.c
-rw-r--r-- 1 user Administrators 73 Oct 10 22:21 HelloWorld.c
-rw-r--r-- 1 user Administrators 73 Oct 10 22:21 hw.c
+ dummy=---------------------------------
  • 1
    Note, even the edited version of your deleted answer (which this is a copy of), still doesn't answer the question, as the answer does not output just the message without the associated echo command, which was what the OP asked for.
    – DavidPostill
    Oct 27, 2016 at 22:06
  • Note, this answer is being discussed on meta Have I been penalized for an answer 1 moderator didn't like?
    – DavidPostill
    Oct 27, 2016 at 22:07
  • @David I have enlarged the explanation, per comments in the meta forum. Oct 30, 2016 at 4:53
  • @David Again I encourage you to read the OP very carefully, paying attention to the Latin ".e.g.". The poster does NOT require the echo command be used. The OP only references echo as an example (along with redirection) of potential methods to solve the problem. Turns out you do not need either, Oct 30, 2016 at 4:55
  • And what if the OP wants to do something like echo "Here are the files in this directory:" *? Nov 1, 2016 at 3:22

In a Makefile you could use the @ symbol.

Example Usage: @echo 'message'

From this GNU doc:

When a line starts with ‘@’, the echoing of that line is suppressed. The ‘@’ is discarded before the line is passed to the shell. Typically you would use this for a command whose only effect is to print something, such as an echo command to indicate progress through the makefile.

  • Hi, the doc you're referencing is for gnu make, not shell. Are you sure it works? I get the error ./test.sh: line 1: @echo: command not found, but I'm using bash.
    – user114447
    May 30, 2017 at 3:19
  • 1
    Wow, sorry I completely misread the question. Yes, @ only works when you are echoing in makefiles.
    – derek
    Jun 8, 2017 at 15:57
  • @derek I edited your original reply so it now clearly states that the solution is limited for Makefiles only. I was actually looking for this one, so I wanted your comment not to have a negative reputation. Hopefully, people will find it helpful too.
    – Shakaron
    Nov 14, 2017 at 1:47

I wanted my shell script to consistent changes if possible. So I just wrote an execute function that just echoes and runs any command.

# Just echo and execute command
# Usage:
# execute "ls -la /home"
# execute 'ls -la /home'
# execute ls -la /home
execute() {
  echo '#' $cmd_str
  # Replace previous line with eval $cmd_str if needed
  # Note: eval is more dangerous
  echo '\n'

Note: Please test in a throwaway VM before using in prod or main setups.

Now I just had to prefix any command line with word execute in the shell script and it works fine. It might seem counter-intuitive but it works for me.

I couldn't find any simple case where didn't work but I've only started using it recently. There might cases with special, escape and/or quote character combination that breaks. Do let me know any issues or improvements.

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .