Doing Date Math on the Command Line, Part I

If you've ever used a spreadsheet, you've probably used or seen functions for doing date math—in other words, taking one date and adding some number of days or months to it to get a new date, or taking two dates and finding the number days between them. The same thing can be done from the command line using the lowly date command, possibly with a little help from Bash's arithmetic.

The two most important features of the date command to understand for doing date math are the +FORMAT option and the --date option. The formatting option allows you to format the date in different ways or get just certain parts of the date, such as the year, the month, the day and so on:

$ date '+%Y'
$ date '+%m'
$ date '+%d'

There are a few dozen formatting options; see the date(1) man page for more.

The second option, the --date (or -d) option, allows you to specify the date to use, rather than the current date:

$ date --date 'March 1, 2015' "+%Y"
$ date --date='March 1, 2015' "+%m"
$ date -d 'March 1, 2015' "+%d"

The interesting part of the --date option is that it allows you to specify date strings in a variety of ways. For example, given the above date of "March 1, 2015", you can calculate dates relative to it:

$ date --date 'March 1, 2015 +7 days'
Sun Mar  8 00:00:00 MST 2015
$ date --date 'March 1, 2015 -1 year'
Sat Mar  1 00:00:00 MST 2014
$ date --date 'March 1, 2015 +12 days +12 hours +15 minutes'
Fri Mar 13 12:15:00 MST 2015

You also can use strings that refer to previous or upcoming days of the week (relative to the current date):

$ date --date 'last Monday'
Mon Sep 10 00:00:00 MST 2018
$ date --date 'next Monday'
Mon Sep 17 00:00:00 MST 2018

Note, however, that combining a date and things like "next Monday" does not work. The date(1) man page has a bit more information about the types of date strings you can specify, however, the GNU Info date page has much more complete documentation.

So now that you've seen the basics of doing date math, let's use it to do something. This example comes from some recent scripts I was working on while organizing Linux Journal's online archives. Each month we publish a new issue. Each issue has an issue number that's one more than the previous issue. Issue numbers are easy to deal with when specifying arguments to a script, but readers tend to think more in terms of months and years. So in a couple places I needed to generate months and years from issue numbers.

To not complicate the example, let's ignore a couple breaks in LJ's publishing history. So, let's use issue #284 from March 2018 as the starting point and make all calculations relative to it. The script takes one or more issue numbers and prints out the month and year corresponding to that issue number:

$ cat
1 for inum in $*
2 do
3     [[ "$inum" =~ ^[0-9]{1,4}$ ]]  ||  { echo "Invalid issue number: $inum"; continue; }
5     month=$(date --date="March 1, 2018 +$((inum - 284)) month" +'%B')
6     year=$(date --date="March 1, 2018 +$((inum - 284)) month" +'%Y')
7     printf "Issue %d is from %s, %s\n" $inum $month $year
8 done
$ bash 284 285
Issue 284 is from March, 2018
Issue 285 is from April, 2018

Lines 5 and 6 calculate the number of issues that have elapsed since issue 284 using Bash's arithmetic expression syntax: $((inum - 284)). That result then is used to add that many months onto the date of the first issue, which gives the date of the issue in question. Then the date command's format options provides the month and year (the %B and %Y format specifiers, respectively). Those values next are used to print out the month and year of the issue. And just in case you were wondering, issue #1000 will be coming out in November 2077.

One thing that can you trip you up when doing date math is that if you use dates near the end of the month when adding or subtracting months, you can end up with unexpected results. This is because the date command uses the same value for the length of each month when it adds months and ignores the fact that not all months are the same length. So, for example, look what happens if you change the reference date in the script above from the start of the month to the end of the month:

$ cat
5     month=$(date --date="March 31, 2018 +$((inum - 284)) month" +'%B')
6     year=$(date --date="March 31, 2018 +$((inum - 284)) month" +'%Y')
$ bash 284 285
Issue 284 is from March, 2018
Issue 285 is from May, 2018

This incorrectly labels issue 285 as being in May rather than April. You can verify this if you run the effective date command by itself:

$ date --date="March 31, 2018 +1 month"
Tue May  1 00:00:00 MST 2018

Since April has only 30 days, you end up skipping to the first of May.

The other common type of date math that I mentioned above is calculating the difference between two dates. Unfortunately, the date command doesn't support this directly. To do that, you need to convert the dates in question into the number of seconds since the epoch (using the %s format specifier), and then subtract the values and divide by the number of seconds in a day (86,400) to get the days in between:

$ cat
start_date="March  1, 2018"
end_date="March 31, 2018"

sdate=$(date --date="$start_date" '+%s')
edate=$(date --date="$end_date"   '+%s')
days=$(( (edate - sdate) / 86400 ))
echo "$days days between $start_date and $end_date"
$ bash
30 days between March  1, 2018 and March 31, 2018

In part two of this article, I plan to tackle how to get a specific previous day of the week relative to a specific date (which, as I noted above, is not directly doable with the date command). In other words, I want to be able to do something that effectively does the following:

$ date --date 'March 1, 2015 previous Monday'   # won't work
date: invalid date ‘March 1 2015 previous Monday’

Mitch Frazier is an embedded systems programmer at Emerson Electric Co. Mitch has been a contributor to and a friend of Linux Journal since the early 2000s.

Load Disqus comments