81 comments

[ 3.3 ms ] story [ 138 ms ] thread
(comment deleted)
(comment deleted)
Cue all the "when your program gets longer than one line you should consider using python" comments.
100% sane advice on the "should" front, but I don't always follow the shoulds..

Bash is just too damn fast to whip things up in (even at the risk of endless footguns). Build up a pipeline of commands, translate into script, done. It's powerful and can scale if common conventions and good practices are used.

For easier debugging I always add a simple flag along the lines of: if $1 = -v then set -x; shift

Obviously shellscripts are a terrible or highly suspect idea when production-grade transactional integrity is critical, but in many cases the swiftness of shell development outweighs the cons when the goal is to Get Things Done.

The shell is a pretty sweet interface.

it's so much easier to glue tools together with it. I'd end up writing more code if I had used python's subprocess package or ruby's IO package. as long as I don't need a complex data structure of some sort.
Last week I wrote a bash script that ended up being about 250 lines. It was mostly running other processes. For technical reasons, it needed to re-invoke itself a few times via sudo.

When I was done I looked over it and thought: this is a nice piece of code right now. It's well documented. It's self contained. It passes my tests. I could maintain this code.

But it made use of quite a few bash'isms: arrays and so forth. I wasn't confident in the ability of my colleagues to keep it in good state.

So I rewrote it in Python. The Python version ended up being a little longer, but that was mostly due to formatting difference. The Python version in addition I could reformat with black and isort, I added type annotations so I could test it with mypy.

(I had formatting the shell version with shfmt and checked it with shellcheck, but still.)

The Python version is much easier to read, and I think will be more maintainable.

I consider myself competent in both languages so it's a courtesy to my colleagues if nothing else to ship the Python version.

I used to think this way, and still do to a large extent. Unfortunately, we still need bash to build software (docker containers for example) so there's a limit to how much bash we can get rid of, unfortunately.
Of course, and I still use it in those places where it's required and relatively self contained, or frankly, if it just happens to be the best tool for the job which it sometimes is.

But now that I can expect python3 to be available on the systems I care about, and with the fairly comfortable ergonomics of subprocess.run, I find myself using Python in places I would've previously used bash.

A good heuristic is the moment a bash script needs to use curl + jq, it's time for python. Especially on macOS it's nice having things like plistlib so I can avoid `defaults` and `PlistBuddy`.

Yawn…

Python sucks for building processing pipelines.

How many lines of Python code would you need to implement this?

  paste -d \\n <(foo …) <(bar …) | while read -r f && read -r b; do
    baz “$f” “$b”
  done
Mind you, `foo` and `bar` run in parallel.
replace bash one-liner with python? No, thanks

replace 200+ bash script with multiple subroutines in python? yes

as someone who is maintaining mountains of legacy bash, for all that is holy, yes. or Go. or Rust. anything.
Mind you, before this goes to prod please add error handling and logging. What happens when bar fails? What about baz? Now, please rewrite this so your team mates can maintain and extend. Yawn, I’ll wait for the PR.
Gotcha, thanks for the feedback! We should also totally A/B test this! I'll make sure that this ties in with the quarterly OKRs, too! I hope I'll get promoted to E6 for that sweet 10% raise next cycle :fingers_crossed:
Nothing stops you from calling bash from Python to combine bash pipelines (onelines) but if you must use Python for some reason, to run `baz` for each line in `foo` & `bar` output:

  from subprocess import Popen, PIPE, run
  
  with Popen('foo', stdout=PIPE) as foo, Popen('bar', stdout=PIPE) as bar:
      for f, b in zip(foo.stdout, bar.stdout):
          run(['baz', f, b])
  
(there is a slight difference in behaviour)
If your primary consideration is that it's generally available on a random developer's machine, what are the options? IOW out of the box on OSX and the major Linux distributions, and readily available on Windows.

Python is pretty safe, but your code would have to work with both 2 & 3 and not pull in any external dependencies. And recommended practices have changed so drastically on how to install Python over the years that a random dev could have theirs in a fairly weird state.

Perl would work, but hacked-together Perl has the similar drawbacks to hacked-together Bash, especially when it's me doing the coding.

Ruby has problems similar to Python.

Make & awk have problems similar to Perl & Bash.

Therefore my latest rust package contains bash scripts.

TCL
That's not installed by default on Linux distributions that I'm aware of. Expect is an awesome tool though.
It's in the Python standard library. Tkinter is just a wrapper around Tcl.
To be fair you can do a lot in a bash one liner.
doesn’t mean you should, for the sake of the next person.
My rule is when you start needing functions or arrays you should consider using something else. Bash is really neat and easy to get started with, but maintaining a large codebase is terrible.
This is a good guide for Bash, but I wouldn't use the code as-is in production. For example, the following code is recommended for cleaning up a log file and leaving only the last 50 lines:

  tail -n $lines messages > mesg.temp
  mv mesg.temp messages
That code has a clear race condition and will lose lines on a busy server.
Yes, use `mktemp', just as you would in Python, Go, Rust, C, or Java.
Actually, to protect against the clobber of the original file, you could use a flocker lock on the process to serialize the operations. Otherwise it will be unsafe.
How would you fix that race condition?
Have the writing process do it.

Which kind of reminds me of that product running on a pseudo-distributed NoSQL database built out of text files with "record per line" structure and sort of a compacting garbage collector for these files to remove dead records. And there's just absolutely nothing that can go wrong with that.

I would rotate the log file, and possibly store only the last log file.
Lots of good refreshers, the cat + heredoc combo is powerful but lots of cryptically subtle variations in behavior between:

<<EOF (expands variables etc)

<<'EOF' (doesn't expand variables etc)

<<-EOF (trims leading tabs)

Am I missing any more of the tricks?

Trimming version does not trim whitespace indentation. I guess point goes to “tabs” camp.
Correct, though you could pipe to sed and trim leading spaces with relative ease:

sed 's/^ *//' <<EOF

Which I'd prefer anyways because it's less cryptic. I try to stay away from the obscure, rarely used notations because I'll always have to look it up in StackOverflow anytime I come back to it. These can be confusingly sharp edges.

You can do it in compound commands as well:

while read -r LINE

do printf %s "$LINE"

done <<FOO

foo

bar

baz

FOO

This is faster than using cat (one less path lookup, one less fork, one less exec, read is builtin), works on all shells.

There is a <<-'EOF' as well (trims left tabs and does not expand vars).

Another cool trick that only bash and zsh have is `printf -v NAME`, which prints to a variable instead of stdout. You can use it to avoid subshells (one less fork for each subshell avoided).

The best guide to using Bash is to read the manual (https://linux.die.net/man/1/bash).

Nobody who has ever used Bash has read the whole manual. It is actually good and useful, please read it. Yes, it is very big. But you will be using this shell for the rest of your life (even if you use Zsh, you will end up using other people's Bash scripts). Consider it an investment in your future.

...and now that I say that - try to restrict shell scripts to POSIX semantics (https://betterprogramming.pub/24-bashism-to-avoid-for-posix-...). POSIX is very simple and most shells can run it. If you need a Bashism, try Bash v3 first so it works on a Mac without Homebrew. Simple is better/portabler than complex. If you need something Bash doesn't provide, use any language that the majority of your team knows well.

If you want to test a specific version of Bash, there's containers @ https://hub.docker.com/_/bash (Macs use v3.2.57). Test POSIX scripts with `bash --posix`. Definitely use Shellcheck.

>Nobody who has ever used Bash has read the whole manual.

Bold claim and patently untrue because I have read the whole manual.

However:

1) Things you don't use often leak out of your brain at a rate that is absurd.

2) Different versions have different features and there's wide variations between versions on different platforms, you can't actually depend on a lot of bash stuff existing, so instead it's usually better to target POSIX sh

I, too, experience Swiss-cheese-brain.

I once thought I’d solve this by taking notes, organizing a journal, and writing ... Well, it was a manual. That had the same problem as other manuals - too long, not gonna read.

Full circle.

Your note taking, organizing and writing will have helped you retain a lot more info for a longer period of time. It wasn't wasted effort. Still, there is a such thing as "too much". Higher degrees of success are achieved by regularly evaluating where you are in the "too little" to "too much" spectrum.
I like making flashcards for myself for new topics that I study, in addition to notes. Of course bash as a whole will not fit on a flashcard, but breaking it into high-level topics. looking through the cards I’ve made helps when I feel the need to brush up on a topic in a general sense before diving deeper.

Despite all this, my brain is still Swiss-cheese.

Careful readers of the manual will find this commentary from the bash maintainers:

  $ man bash | sed -n '/BUGS/,/^$/p'
  BUGS
         It's too big and too slow.
It is at this point that such a reader will discover the reasons behind Debian dash.
Not everyone has portability as a first-class requirement for their scripts. For 95% of what I write, bash 4 is entirely appropriate.
It's more about simplicity than portability. POSIX is simpler and simpler is better. If what you need is impossible with POSIX, then use Bash. If it's impossible with Bash, then use something else.
> Test POSIX scripts with `bash --posix`. Definitely use Shellcheck.

Except that doesn't disable all bashisms so you end up leaving in bashisms as a result, which is what caused all the issues for debian trying to migrate to dash

> try to restrict shell scripts to POSIX semantics

I have to admit, while i admire the idea of and understand the reasoning behind striving for POSIX-compatibility, i never couldn't fully get past viewing this as some kind of hypothetical exercise. The chance that any script i (or presumably most people who get this advice on pages like SO) will ever write will have to run on a 25 years old Tru64 machine or even a modern Mac, BSD, QNX, … is relatively small. Truth is (in my case), most of the scripts will never even run on anything but the system they were written on. As i said, i admire the idealism though.

It is easier than you think.

First, we get rid of PATH= inside scripts. A lot of incompatibilites comes from /bin, not the shell itself. Treat executables like services, rewrite everything (reasonable) in pure shell.

The scenario I'm trying to prevent is "I want to cut a string in OSX but I wrote for a GNU executable and the syntax is slightly different". Most shell one liners can be rewritten as good quality portable code.

If you take just Debian, there are 5 or 6 shells available via apt. These should run wherever Debian runs (x86, arm, hurd, whatever).

You can't write a reasonable JavaScript that runs on JScript.exe (it still exists), Apple JXA, gjs and node.js. But you _could_, the language is stable enough, for long enough, that everyone knows how to feature detect the shit out of everything js related.

For the shell though, we don't even have a solid list of things that work or not in each shell, or differences across core utilities. Something like caniuse.com, but for UNIX shells, not browsers.

Having a page that runs on all browsers was once something impractical and idealistic. Not using tables and invisible 1x1 pixels for layouts was once idealistic. We fixed it though. Mostly.

Many of my scripts run on my computer, but also have to run on Alpine Linux because that's what my employer's CI system exposes. That means Busybox for the common shell utilities (unless the script or job installs something else as needed) and thus POSIX compatibility is important.
Many people who write in Bash end up writing in Bash v4, which won't work on a Mac until you install bash from Homebrew. Whereas POSIX just works. If you need to run a script on an Alpine Linux container and didn't install bash, that would be using ash, so again POSIX would work.

But the reason to use POSIX scripting isn't portability, it's simplicity. Simple scripts are just easier to reason about, easier to maintain, and are less prone to bugs. The code might be a little more verbose, but its simplicity still reduces the cost of complexity.

Most people don't realize this until they've been writing scripts for 15 years and realize they can do almost everything with POSIX and that it makes their life easier in the long run. But in some cases what I want to do would really benefit from Bash, so I use Bash then. It's not a hard and fast rule; I switch based on what would work best for that case. It's about balance, compromise, and being flexible.

A quick ToC for the bash manual: 'man bash | grep ^[A-Z]'
I think that fish is supplanting zsh as the "developer's" shell. Bash is like C though, gonna be here for another century at least.
I am on the portable shell team. It is hard to write for all shells, but totally worth it. If we all did it, reusing shell code would be easier.

We never left the hell caused by the war between shells to provide interactive features. They were all feature creeped for terminal usage and the scripting API was left to rot.

I get the people that say we should use python or something else instead. It won't work though if that language is not capable of replacing the shell _as a build dependency_. As long as we need the shell to build stuff, we're stuck to it.

Dockerfiles, GitHub Actions YAMLs, all these tools accept shell input. This is shiny new software that could have picked any language for their proprietary DSLs, but the shell was chosen.

What the shell needs is what happened to JavaScript around the 2000s. Different interpreters started to align, standards were written, reusable libraries started to pop up everywhere, and now it runs in our brains. Before all of that, JavaScript was merely a cute toy to animate pages.

The one thing I really miss in portable shell code is arrays.

There’s a place reserved in hell for whoever thought it was a good idea to design a build system around shell code in yaml files.

I’m in the process of moving thousands of lines of such code into standalone scripts that then get called from the yaml files. Keep the yaml as minimal as possible. Then I can shellcheck the shell code or easily port bits to other languages as needed.

You can reimplement arrays using pure shell!

I saw it first here: https://github.com/shellfire-dev/core/blob/master/variable/a...

For many cases, this is even better than bash native arrays. You can pass these as arguments and preserve the structure, while the bash ones will autoexpand if you try that.

I ended up cooking my own array implementation later, but it is a bit more unorthodox (I create extra vars with numeric pointers to handle the values). Let me know if you want to see it, it's not complete but it works.

I'm not very fond of YAML either, I feel your pain :(

I knew I should have qualified my comment about arrays with: “without using a hack such as storing the values in a string with a custom delimiter.” Those aren’t really arrays.

What happens if the delimiter appears among the values you want to store? This is a pretty big disclaimer:

> Do not use arrays with file paths if there's any chance they could contain \r.

That's why I did my own implementation using pointers.

var lorem = text_create "Hello"

var foo = array_create

array_push $foo $lorem

`var`, `array_push` and `array_create` are shell functions working together. They create pointers and return just integers. You don't even have to quote the variable.

The code above should create the following variables:

_123="Hello" _124=1 _124_0=_123 foo=_124 lorem=_123

This way, I can even implement more complex data structures.

The numbers start from zero and the program will stop if the global variable counter overflows. My current implementation also has a primitive GC (ARC-based), which could be used to avoid that (but I didn't, yet).

Despite all of this runtime processing and jerry rigging, I have some evidence that it could be faster than typical shell scripts. One single subshell or path lookup is often more expensive than these fake pointer arrays. These strucutres have zero subshells and run on PATH='', just with builtins. The subshell only becomes faster if the volume of data is larger than a few kilobytes (when the cost of forking and exec'in is worth the payoff).

I know this is far from ideal. I should try to put these ideas in a new interpreter, not hack them on existing ones (JS calls them polyfills, maybe I should do that). If I did another interpreter though, it would end up like the fish shell (nice, fast, doesn't compile the kernel and no one uses it).

Maybe I should probably stick to bash, ksh and zsh that offers the most complete set of features. Sounds like "best viewed in internet explorer" though.

That's an impressive level of devotion to portable bash arrays.
ASCII has infrequently used control chars meant to serve as separators.
(comment deleted)
Sometimes I pause to think how we're held back by needing to make our shell scripts backwards compatible with Bash or POSIX, because that's what is installed by default. Shell scripting would be nicer if the shell could be less crufty. For example, get rid of all the footguns about expanding unquoted variables...
> Sometimes I pause to think how we're held back by needing to make our shell scripts backwards compatible with Bash or POSIX, because that's what is installed by default.

I think you're missing the forest for the trees.

Bash is installed by default because Bash is specified in an international standard dubbed the Portable Operating System Interface (POSIX). Your bash scripts are not backwards compatible. Your bash scripts are compatible, and portable, and standardized, because that's their point, and the whole point of POSIX.

No one is held back by bash or POSIX. You are free to use anything that suits your fancy. Feel free to whip out scripts in Python or Perl or Ruby. Some people do, and last time I checked some operating systems like macOS shipped their interpreters by default. Odds are you are free to easily install those interpreters in those who don't.

> No one is held back by bash or POSIX. You are free to use anything that suits your fancy. Feel free to whip out scripts in Python or Perl or Ruby.

The point is that there are many improvements that could be made to bash which would retain the good properties but would get rid of the worst footguns and suckiness. Quite a number of these (though not all) have been available for decades in the form of zsh.

Also, as pointed out already in another comment, bash is not in POSIX at all. POSIX is very much a "lowest common denominator"-kind of specification which moves at geological speeds. Even dash and busybox sh implement some non-POSIX features.

> get rid of all the footguns about expanding unquoted variables

The shell's power & it's footguns are one in the same. "Fix" them, and becomes a general-purpose language--a jack of all trades & master of none. CS professors will love it, but doing anything meaningful in the shell will look more like a one-page python script instead of the l337 one-liners the Jersey boys graced us with.

I've seen so many "bash but with type inference, unit testing, and all those nice features to make your code safer."

I want to see someone try "bash but we really embrace the insanity."

For instance, make recursively affecting all files in subdirectories the default? Or have a global option where any prompt is automatically answered with yes? Maybe some heuristics to detect if the user wants unquoted variables expanded?

APL also has great one-liners if that is any help as a potential breeding pair.
Oh some real terse syntax I like it!
> For example, get rid of all the footguns about expanding unquoted variables

That's what zsh did; $var is always the value of $var. Want field splitting? Use $=var. Want globbing? Use $~var.

I think bash is stuck in 1988 and is a poor clone of ksh. I think it's highly unfortunate it became the "de-facto 'modern' standard".

>I think it's highly unfortunate it became the "de-facto 'modern' standard".

Perhaps you're right. Perhaps. However, the most important consequence of bash creation and dissemination is that there is a de-facto standard.

So until we don't have another de-facto standard hundreds of thousands of typical shell users are happy with bash. With all its issues, weaknesses and disadvantages...

I'm not so sure people are "happy" with bash, given the complaints that keep returning, and that many aren't aware that zsh is actually different in this regard.

I also think the value of being the de-facto standard is somewhat overrated (as is the value of portability by the way); many scripts only run on one platform, one you control. zsh is not hard to install.

Doesn't this get posted like every other month?
Checking HN Search (the box at the bottom of the page), this link has only been posted 3 times ever, last time being 11 months ago (and before that, 4 years ago).
Do not follow this guide. It is outdated and IIRC in some places actually wrong.

Much, much better is the Bash Hacker's Wiki:

https://wiki.bash-hackers.org/

And Wooledge's Wiki:

https://mywiki.wooledge.org/BashFAQ

Thanks and very much agreed, the resources in your comment are much better than the OP.
The Bash Hacker's Wiki is not sequentially structured like this, and I observe that it even links to OP under "Recommended Shell resources."
Another simple trick that I almost forgot:

You don't need to escape new lines if you end them with an operator:

test 1 = 1 &&

test a = b ||

exit 122

Works with all logical operators, ampersand and pipe. By the way, the ampersand is often expressed with a new line after it, but it doesn't require it:

this_in_background & this_in_foreground

This is the same as writing:

this_in_background &

this_in_foreground

I’m sure I saw a quote from a bash developer saying that bash shouldn’t be considered for security, but dont recall where I saw it. I’d love to know a source if anyone knows where I can find it.
No need to quote a security expert to realize that.

A language that mixes code and data as the same variable type, with only a single missing quote needed to turn any variable into a full RCE. On top of that, add all the obscure syntax, stringly typed data for everything and with whatever measly error handling it actually does support being defaulted to off. Guess what you get.