Update

A good explaintation of what is happening is provided here. In this day and age dateutil.tz is the better solution to timezones, as it works as expected with datetime.

TL;DR

The crux of this post is, make sure to never use datetime.replace(tzinfo=...) when working with PyTZ, use tz.localize(...) instead, otherwise you’ll end up with some very strange times.

The PyTZ docs do mention this helper method as a way to fix incorrect conversion across timezones, but out of the box PyTZ timezones seem odd. Consider this simple code that takes both the native datetime.replace approach, and the localize approach:

import pytz
import datetime

lunchtime = datetime.datetime(2014, 11, 12, 12, 30)
print 'lunchtime =', lunchtime

local_tz = pytz.timezone('Australia/Brisbane')
print 'local_tz =', repr(local_tz)

lunchtime_local = lunchtime.replace(tzinfo=local_tz)
print 'lunchtime_local =', lunchtime_local
print 'lunchtime_local to UTC =', lunchtime_local.astimezone(pytz.utc)

lunchtime_localize = local_tz.localize(lunchtime)
print 'lunchtime_localize =', lunchtime_localize
print 'lunchtime_localize to UTC =', lunchtime_localize.astimezone(pytz.utc)

The output is:

lunchtime = 2014-11-12 12:30:00
local_tz = <DstTzInfo 'Australia/Brisbane' LMT+10:12:00 STD>
lunchtime_local = 2014-11-12 12:30:00+10:12
lunchtime_local to UTC = 2014-11-12 02:18:00+00:00
lunchtime_localize = 2014-11-12 12:30:00+10:00
lunchtime_localize to UTC = 2014-11-12 02:30:00+00:00

Off the bat, notice that the representation of the PyTZ timezone is strange: “LMT+10:12:00”. For a start, what is LMT? And why is there an extra 12 minutes in the offset? The correct format would be something like: AEST+10:00:00. i.e., the timezone abbreviation is AEST, and the offset is 10 hours.

When you apply this in a naive way you end up with the incorrect offset, and when you take this across to UTC the answer is wrong. But, when you apply the timezone using localize method the correct offset.

Certainly something to be aware of.