Chapter 11. Working with Dates and Times

Working with dates and times should be simple, but it’s not. Regardless of whether you’re writing a shell script or a much larger program, timekeeping is full of complexities: different formats for displaying the time and date, Daylight Saving Time, leap years, leap seconds, and all of that. For example, imagine that you have a list of contracts and the dates on which they were signed. You’d like to compute expiration dates for all of those contracts. It’s not a trivial problem: does a leap year get in the way? Is it the sort of contract where Daylight Saving Time is likely to be a problem? And how do you format the output so that it’s unambiguous? Does 7/4/07 mean July 4, 2007, or does it mean April 7?

Dates and times permeate every aspect of computing. Sooner or later you are going to have to deal with them: in system, application, or transaction logs; in data processing scripts; in user or administrative tasks; and more. This chapter will help you deal with them as simply and cleanly as possible. Computers are very good at keeping time accurately, particularly if they are using the Network Time Protocol (NTP) to keep themselves synced with national and international time standards. They’re also great at understanding the variations in Daylight Saving Time from locale to locale. To work with time in a shell script, you need the Unix date command (or even better, the GNU version of the date command, which is standard on Linux). date is capable of displaying dates in different formats and even doing date arithmetic correctly.

Note that gawk (the GNU version of awk) has the same strftime formatting as the GNU date command. We’re not going to cover gawk usage here except for one trivial example. We recommend sticking with GNU date because it’s much easier to use and it has the very useful -d argument. But keep gawk in mind should you ever encounter a system that has gawk but not GNU date.

11.1 Formatting Dates for Display

Problem

You need to format dates or times for output.

Solution

Use the date command with a strftime format specification. See “Date and Time String Formatting with strftime” in Appendix A or the strftime manpage for the list of format specifications supported:

# Setting environment variables can be helpful in scripts:
$ STRICT_ISO_8601='%Y-%m-%dT%H:%M:%S%z' # Strict ISO 8601 format
$ ISO_8601='%Y-%m-%d %H:%M:%S %Z'       # Almost ISO8601, but more human-readable
$ ISO_8601_1='%Y-%m-%d %T %Z'           # %T is the same as %H:%M:%S
$ DATEFILE='%Y%m%d%H%M%S'               # Suitable for use in a filename

$ date "+$ISO_8601"
2006-05-08 14:36:51 CDT

$ gawk "BEGIN {print strftime(\"$ISO_8601\")}"
2006-12-07 04:38:54 EST

# Same as previous $ISO_8601
$ date '+%Y-%m-%d %H:%M:%S %Z'
2006-05-08 14:36:51 CDT

$ date -d '2005-11-06' "+$ISO_8601"
2005-11-06 00:00:00 CST

$ date "+Program starting at: $ISO_8601"
Program starting at: 2006-05-08 14:36:51 CDT

$ printf "%b" "Program starting at: $(date '+$ISO_8601')\n"
Program starting at: $ISO_8601

$ echo "I can rename a file like this: mv file.log file_$(date +$DATEFILE).log"
I can rename a file like this: mv file.log file_20060508143724.log

Discussion

You may be tempted to place the + in the environment variable to simplify the later command, but some systems the date command is more picky about the existence and placement of the + than on others. Our advice is to explicitly add it to the date command itself.

Many more formatting options are available; see the date manpage or the C strftime() function (man 3 strftime) on your system for a full list.

Unless otherwise specified, the time zone is assumed to be local time as defined by your system. The %z format is a nonstandard extension used by the GNU date command; it may not work on your system.

ISO 8601 is the recommended standard for displaying dates and times and should be used if at all possible. It offers a number of advantages over other display formats:

  • It is a recognized standard.

  • It is unambiguous.

  • It is easy to read while still being easy to parse programmatically (e.g., using awk or cut).

  • It sorts as expected when used in columnar data or in filenames.

Try to avoid MM/DD/YY or DD/MM/YY (or even worse, M/D/YY or D/M/YY) formats. They do not sort well and they are ambiguous, since either the day or the month may come first depending on geographical location, which also makes them hard to parse. Likewise, use 24-hour time when possible to avoid even more ambiguity and parsing problems.

11.2 Supplying a Default Date

Problem

You want your script to provide a useful default date, and perhaps prompt the user to verify it.

Solution

Using the GNU date command, assign the most likely date to a variable, then allow the user to change it (see Example 11-1).

Example 11-1. ch11/default_date
#!/usr/bin/env bash
# cookbook filename: default_date

# Use noon time to prevent a script running around midnight and a clock a
# few seconds off from causing off by one day errors.
START_DATE=$(date -d 'last week Monday 12:00:00' '+%Y-%m-%d')

while [ 1 ]; do
    printf "%b" "The starting date is $START_DATE, is that correct? (Y/new date)"
    read answer

    # Anything other than ENTER, "Y", or "y" is validated as a new date
    # Could use "[Yy]*" to allow the user to spell out "yes"...
    # Validate the new date format as: CCYY-MM-DD
    case "$answer" in
        [Yy]) break
            ;;
        [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9])
            printf "%b" "Overriding $START_DATE with $answer\n"
            START_DATE="$answer"
            ;;

        *)   printf "%b" "Invalid date, please try again...\n"
            ;;
    esac
done

END_DATE=$(date -d "$START_DATE +7 days" '+%Y-%m-%d')

echo "START_DATE: $START_DATE"
echo "END_DATE:   $END_DATE"

Discussion

Not all date commands support the -d option, but the GNU version does. Our advice is to obtain and use the GNU date command if at all possible.

Leave out the user verification code if your script is running unattended or at a known time (e.g., from cron).

See Recipe 11.1 for information about how to format the dates and times.

We use code like this in scripts that generate SQL queries. The script runs at a given time and creates a SQL query for a specific date range to generate a report.

11.3 Automating Date Ranges

Problem

You have one date (perhaps from Recipe 11.2) and you would like to generate another automatically.

Solution

The GNU date command is very powerful and flexible, but the power of -d isn’t documented well. Your system may document it under getdate (try the getdate manpage). Here are some examples:

$ date '+%Y-%m-%d %H:%M:%S %z'
2005-11-05 01:03:00 -0500

$ date -d 'today' '+%Y-%m-%d %H:%M:%S %z'
2005-11-05 01:04:39 -0500

$ date -d 'yesterday' '+%Y-%m-%d %H:%M:%S %z'
2005-11-04 01:04:48 -0500

$ date -d 'tomorrow' '+%Y-%m-%d %H:%M:%S %z'
2005-11-06 01:04:55 -0500

$ date -d 'Monday' '+%Y-%m-%d %H:%M:%S %z'
2005-11-07 00:00:00 -0500

$ date -d 'this Monday' '+%Y-%m-%d %H:%M:%S %z'
2005-11-07 00:00:00 -0500

$ date -d 'last Monday' '+%Y-%m-%d %H:%M:%S %z'
2005-10-31 00:00:00 -0500

$ date -d 'next Monday' '+%Y-%m-%d %H:%M:%S %z'
2005-11-07 00:00:00 -0500

$ date -d 'last week' '+%Y-%m-%d %H:%M:%S %z'
2005-10-29 01:05:24 -0400

$ date -d 'next week' '+%Y-%m-%d %H:%M:%S %z'
2005-11-12 01:05:29 -0500

$ date -d '2 weeks' '+%Y-%m-%d %H:%M:%S %z'
2005-11-19 01:05:42 -0500

$ date -d '-2 weeks' '+%Y-%m-%d %H:%M:%S %z'
2005-10-22 01:05:47 -0400

$ date -d '2 weeks ago' '+%Y-%m-%d %H:%M:%S %z'
2005-10-22 01:06:00 -0400

$ date -d '+4 days' '+%Y-%m-%d %H:%M:%S %z'
2005-11-09 01:06:23 -0500

$ date -d '-6 days' '+%Y-%m-%d %H:%M:%S %z'
2005-10-30 01:06:30 -0400

$ date -d '2000-01-01 +12 days' '+%Y-%m-%d %H:%M:%S %z'
2000-01-13 00:00:00 -0500

$ date -d '3 months 1 day' '+%Y-%m-%d %H:%M:%S %z'
2006-02-06 01:03:00 -0500

Discussion

The -d option allows you to specify a specific date instead of using “now,” but not all date commands support it. The GNU version does, and our advice is to obtain and use that version if at all possible.

Using -d can be tricky. These arguments work as expected:

$ date '+%a %Y-%m-%d'
Sat 2005-11-05

$ date -d 'today' '+%a %Y-%m-%d'
Sat 2005-11-05

$ date -d 'Saturday' '+%a %Y-%m-%d'
Sat 2005-11-05

$ date -d 'last Saturday' '+%a %Y-%m-%d'
Sat 2005-10-29

$ date -d 'this Saturday' '+%a %Y-%m-%d'
Sat 2005-11-05

But if you run this on Saturday, you would expect to see next Saturday but instead will get today:

$ date -d 'next Saturday' '+%a %Y-%m-%d'
Sat 2005-11-05
$

Also watch out for this week day, because as soon as the specified day is in the past, this week becomes next week. So, if you ran the following command on Saturday 2005-11-05 you would get these results, which may not be what you were expecting:

$ date -d 'this week Friday' '+%a %Y-%m-%d'
Fri 2005-11-11

The -d options can be incredibly useful, but be sure to thoroughly test your code and provide appropriate error checking.

If you don’t have GNU date, you may find the following shell functions’ presented in “Shell Corner: Date-Related Shell Functions” in the September 2005 issue of Unix Review, to be useful:

pn_month

Previous and next x months relative to the given month

end_month

End of month of the given month

pn_day

Previous and next x days relative to the given day

cur_weekday

Day of week for the given day

pn_weekday

Previous and next x days of the week relative to the given day

And these are available in newer versions of bash:

pn_day_nr

(Nonrecursive) Previous and next x days relative to the given day

days_between

Number of days between two dates

Note that pn_month, end_month, and cur_weekday are independent of the rest of the functions. However, pn_day is built on top of pn_month and end_month, and pn_weekday is built on top of pn_day and cur_weekday.

11.4 Converting Dates and Times to Epoch Seconds

Problem

You want to convert a date and time to epoch seconds to make it easier to do date and time arithmetic.

Solution

Use the GNU date command with the nonstandard -d option and a standard %s format:

# "Now" is easy
$ date '+%s'
1131172934

# Some other time needs the nonstandard -d
$ date -d '2005-11-05 12:00:00 +0000' '+%s'
1131192000

Epoch seconds are simply the number of seconds since the epoch (which is midnight on January 1, 1970, also known as 1970-01-01T00:00:00). This command simply starts at the epoch, adds the epoch seconds, and displays the date and time as you wish.

Discussion

If you do not have the GNU date command available, this is a harder problem to solve. Our advice is to obtain and use the GNU date command if at all possible. If that is not possible, you might be able to use Perl. Here are three ways to print the time right now in epoch seconds:

$ perl -e 'print time, qq(\n);'
1154158997

# Same as above
$ perl -e 'use Time::Local; print timelocal(localtime()) . qq(\n);'
1154158997

$ perl -e 'use POSIX qw(strftime); print strftime("%s", localtime()) . qq(\n);'
1154159097

Using Perl to convert a specific day and time instead of “right now” is even harder due to Perl’s date/time data structure. Years start at 1900 and months (but not days) start at 0 instead of 1. The format of the command is: timelocal(sec, min, hour, day, month-1, year-1900). So, to convert 2005-11-05 06:59:49 to epoch seconds:

# The given time is in local time
$ perl -e 'use Time::Local;
> print timelocal("49", "59", "06", "05", "10", "105") . qq(\n);'
1131191989

# The given time is in UTC time
$ perl -e 'use Time::Local;
> print timegm("49", "59", "06", "05", "10", "105") . qq(\n);'
1131173989

11.5 Converting Epoch Seconds to Dates and Times

Problem

You need to convert epoch seconds to a human-readable date and time.

Solution

Use the GNU date command with your desired format from Recipe 11.1:

$ EPOCH='1131173989'

$ date -d "1970-01-01 UTC $EPOCH seconds" +"%Y-%m-%d %T %z"
2005-11-05 01:59:49 -0500

$ date --utc --date "1970-01-01 $EPOCH seconds" +"%Y-%m-%d %T %z"
2005-11-05 06:59:49 +0000

Discussion

If you don’t have GNU date on your system you can try one of these Perl one-liners:

$ EPOCH='1131173989'

$ perl -e "print scalar(gmtime($EPOCH)), qq(\n);" # UTC
Sat Nov 5 06:59:49 2005

$ perl -e "print scalar(localtime($EPOCH)), qq(\n);" # Your local time
Sat Nov 5 01:59:49 2005

$ perl -e "use POSIX qw(strftime);
> print strftime('%Y-%m-%d %H:%M:%S',localtime($EPOCH)), qq(\n);"
2005-11-05 01:59:49

11.6 Getting Yesterday or Tomorrow with Perl

Problem

You need to get yesterday or tomorrow’s date, and you have Perl but not GNU date on your system.

Solution

Use these Perl one-liners, adjusting the number of seconds added to or subtracted from time:

# Yesterday at this same time (note subtraction)
$ perl -e "use POSIX qw(strftime);
> print strftime('%Y-%m-%d', localtime(time - 86400)), qq(\n);"
2005-11-04

# Tomorrow at this same time (note addition)
$ perl -e "use POSIX qw(strftime);
> print strftime('%Y-%m-%d', localtime(time + 86400)), qq(\n);"
2005-11-06

Discussion

This is really just a specific application of the preceding recipes, but it’s so common that it’s worth talking about by itself. See Recipe 11.7 for a handy table of values that may be of use.

11.7 Figuring Out Date and Time Arithmetic

Problem

You need to do some kind of arithmetic with dates and times.

Solution

If you can’t get the answer you need using the date command (see Recipe 11.3), convert your existing dates and times to epoch seconds using Recipe 11.4, perform your calculations, then convert the resulting epoch seconds back to your desired format using Recipe 11.5.

Tip

If you don’t have GNU date, you may find the shell functions presented in “Shell Corner: Date-Related Shell Functions” in the September 2005 issue of Unix Review to be very useful. See Recipe 11.3.

For example, suppose you have log data from a machine where the time was badly off. Everyone should already be using the Network Time Protocol so this doesn’t happen, but just suppose:

CORRECTION='172800' # 2 days' worth of seconds

# Code to extract the date portion from the data
# into $bad_date goes here

# Suppose it's this:
bad_date='Jan 2 05:13:05' # syslog-formatted date

# Convert to epoch second using GNU date
bad_epoch=$(date -d "$bad_date" '+%s')

# Apply correction
good_epoch=$(( bad_epoch + $CORRECTION ))

# Make corrected date human-readable, with GNU date
good_date=$(date -d "1970-01-01 UTC $good_epoch seconds")
good_date_iso=$(date -d "1970-01-01 UTC $good_epoch seconds" +'%Y-%m-%d %T')
Date

echo "bad_date:        $bad_date"
echo "bad_epoch:       $bad_epoch"
echo "Correction:      +$CORRECTION"
echo "good_epoch:      $good_epoch"
echo "good_date:       $good_date"
echo "good_date_iso:   $good_date_iso"

# Code to insert the $good_date back into the data goes here
Warning

Watch out for years! Some Unix commands, like ls and syslog, try to be easy to read and omit the year under certain conditions. You may need to take that into account when calculating your correction factor. If you have data from a large range of dates or from different time zones, you will have to find some way to break it into separate files and process them individually.

Discussion

Dealing with any kind of date arithmetic is much easier using epoch seconds than any other format of which we are aware. You don’t have to worry about hours, days, weeks, or years; you just do some simple addition or subtraction and you’re all set. Using epoch seconds also avoids all the convoluted rules about leap years and seconds, and if you standardize on one time zone (usually UTC, which used to be called GMT) you can even avoid time zones.

Table 11-1 lists values that may be of use.

Table 11-1. Conversion table of common epoch time values
Seconds Minutes Hours Days

60

1

300

5

600

10

3,600

60

1

18,000

300

5

36,000

600

10

86,400

1,440

24

1

172,800

2,880

48

2

604,800

10,080

168

7

1,209,600

20,160

336

14

2,592,000

43,200

720

30

31,536,000

525,600

8,760

365

11.8 Handling Time Zones, Daylight Saving Time, and Leap Years

Problem

You need to account for time zones, Daylight Saving Time, and leap years or seconds.

Solution

Don’t.

Discussion

This is a lot trickier than it sounds. Leave it to code that’s already been in use and debugged for years, and just use a tool that can handle your needs. Odds are high that one of the other recipes in this chapter has covered what you need, probably using GNU date. If not, there is almost certainly another tool out there that can do the job. For example, there are a number of excellent Perl modules that deal with dates and times.

Really, we aren’t kidding. This is a real nightmare to get right. Save yourself a lot of agony and just use a tool.

11.9 Using date and cron to Run a Script on the Nth Day

Problem

You need to run a script on the Nth weekday of the month (e.g., the second Wednesday), and most crons will not allow that.

Solution

Use a bit of shell code in the command to be run. In your Linux Vixie-cron crontab, adapt one of the following lines. If you are using another cron program, you may need to convert the day of the week names to numbers according to the schedule your cron uses (0–6 or 1–7) and use +%w (day of week as number) in place of +%a (locale’s abbreviated weekday name):

# Vixie-cron
# Min Hour DoM Mnth DoW Program
# 0-59 0-23 1-31 1-12 0-7

# Vixie-cron requires % to be escaped or you get an error!

# Run the first Wednesday @ 23:00
00 23 1-7 * Wed [ "$(date '+\%a')" == "Wed" ] && /path/to/command args to command

# Run the second Thursday @ 23:00
00 23 8-14 * Thu [ "$(date '+\%a')" == "Thu" ] && /path/to/command

# Run the third Friday @ 23:00
00 23 15-21 * Fri [ "$(date '+\%a')" == "Fri" ] && /path/to/command

# Run the fourth Saturday @ 23:00
00 23 22-27 * Sat [ "$(date '+\%a')" == "Sat" ] && /path/to/command

# Run the fifth Sunday @ 23:00
00 23 28-31 * Sun [ "$(date '+\%a')" == "Sun" ] && /path/to/command
Warning

Note that any given day of the week doesn’t always happen five times during one month, so be sure you really know what you are asking for if you schedule something for the fifth week of the month.

Also note that Vixie-cron requires a % to be escaped or you get an error like “Syntax error: EOF in backquote substitution.” Other versions of cron may not require this, so check your manpage.

Note

If cron seems like it’s not working, try restarting your MTA (e.g., sendmail). Some versions of cron on some systems, such as Vixie-cron on Red Hat, are tied into the sendmail process.

Discussion

Most versions of cron (including Linux’s Vixie-cron) do not allow you to schedule a job on the Nth day of the month. To get around that, we schedule the job to run during the range of days when the Nth day we need occurs, then check to see if it is the correct day on which to run. The “second Wednesday of the month” must occur somewhere in the range of the 8th to 14th day of the month, so we simply run every day and see if it’s Wednesday. If so, we execute our command.

Table 11-2 shows the ranges noted in the solution.

Table 11-2. Day ranges for each week of a month
Week Day range

First

1 to 7

Second

8 to 14

Third

15 to 21

Fourth

22 to 27

Fifth (see previous warning)

28 to 31

We know this almost seems too simplistic; check a calendar if you don’t believe us:

$ cal 10 2006
   October 2006
 S  M  Tu   W  Th   F   S
 1  2   3   4   5   6   7
 8  9  10  11  12  13  14
15 16  17  18  19  20  21
22 23  24  25  26  27  28
29 30  31
$

See Also

  • man 5 crontab

  • man cal

11.10 Logging with Dates

Problem

You want to output logs or other lines with dates, but you want to avoid the overhead of shelling out to the date command.

Solution

As of bash 4 or newer, you can use printf '%(fmt)T' for dates and times:

printf '%(%F %T)T; Foo Bar\n' '-1'

You can also use printf to assign to a variable, so you can easily reuse it:

printf -v today '%(%F)T' '-1'    # Set $today = '2014-11-15'

Discussion

The '-1' argument is important, and inconsistent! The bash manpage says:

Two special argument values may be used: -1 represents the current time, and -2 represents the time the shell was invoked.

But the default behavior changed between bash 4.2 and 4.3. In 4.2, a null argument is treated as null, which will return the local time at the Unix epoch, which is almost certainly not what you want or expect. In 4.3 there is a special exception so that a null argument is treated as a '-1' argument. For example:

$ echo $BASH_VERSION
4.2.37(1)-release

$ printf '%(%F %T %Z)T; Foo Bar\n'
1969-12-31 19:00:00 EST; Foo Bar

$ printf '%(%F %T %Z)T; Foo Bar\n' '-1'
2014-11-15 15:24:26 EST; Foo Bar

$ echo $BASH_VERSION
4.3.11(1)-release

$ printf '%(%F %T %Z)T; Foo Bar\n'
2014-11-15 15:25:02 EST; Foo Bar

$ printf '%(%F %T %Z)T; Foo Bar\n' '-1'
2014-11-15 15:25:05 EST; Foo Bar
Note

The printf in bash is a builtin command, but there is also a separate binary executable called printf which isn’t the same. The separate executable is for other shells that don’t have a builtin printf. So, don’t confuse the manpage for printf with the description of printf that is part of the bash manpage. Though there are large similarities between the two, the latter is what you want.