I did some digging the other day to try to figure out how to use the excellent Python datetime library Arrow with the workhorse psycopg2 Python-PostgreSQL database adapter (plus the nifty Peewee ORM on top of psycopg2). I was pleasantly surprised how easy and painless it was to implement, with help from a blog post by Omar Rayward, and the psycopg2 docs (and source code) as a guide.
There are 5 core PostgreSQL date/time types that Arrow can handle, which psycopg2 maps to the 3 core Python date/time classes — by default through 4 core psycopg2 datatypes:
PostgreSQL Type | Example Output | Psycopg2 Type | Python Type |
---|---|---|---|
timestamp [without time zone] | 2001-02-03 04:05:06 | PYDATETIME | datetime |
timestamp with time zone | 2001-02-03 04:05:06-07 | PYDATETIMETZ | datetime |
date | 2001-02-03 | PYDATE | date |
time [without time zone] | 04:05:06 | PYTIME | time |
time with time zone | 04:05:06-07 | PYTIME | time |
Arrow can be used to handle each of these 5 types, via its single Arrow class. Here's how you set up the mappings:
import arrow
import psycopg2.extensions
def adapt_arrow_to_psql(value):
"""Formats an Arrow object as a quoted string for use in a SQL statement."""
# assume Arrow object is being used for TIME datatype if date is 1900 or earlier
if value.year <= 1900:
value = value.format("HH:mm:ss.SZ")
elif value == arrow.Arrow.max:
value = "infinity"
elif value == arrow.Arrow.min:
value = "-infinity"
return psycopg2.extensions.AsIs("'{}'".format(value))
# register adapter to format Arrow objects when passed as parameters to SQL statements
psycopg2.extensions.register_adapter(arrow.Arrow, adapt_arrow_to_psql)
def cast_psql_date_to_arrow(value, conn):
"""Parses a SQL timestamp or date string to an Arrow object."""
# handle NULL and special "infinity"/"-infinity" values
if not value:
return None
elif value == "infinity":
return arrow.Arrow.max
elif value == "-infinity":
return arrow.Arrow.min
return arrow.get(value)
def cast_psql_time_to_arrow(value, conn):
"""Parses a SQL time string to an Arrow object."""
# handle NULL
if not value:
return None
# handle TIME, TIME with fractional seconds (.S), and TIME WITH TIME ZONE (Z)
return arrow.get(value, ["HH:mm:ss", "HH:mm:ss.S", "HH:mm:ssZ", "HH:mm:ss.SZ"])
# override default timestamp/date converters
# to convert from SQL timestamp/date results to Arrow objects
psycopg2.extensions.register_type(psycopg2.extensions.new_type(
(
psycopg2.extensions.PYDATETIME.values +
psycopg2.extensions.PYDATETIMETZ.values +
psycopg2.extensions.PYDATE.values
),
"ARROW",
cast_psql_date_to_arrow,
))
# override default time converter to convert from SQL time results to Arrow objects
psycopg2.extensions.register_type(psycopg2.extensions.new_type(
psycopg2.extensions.PYTIME.values, "ARROW_TIME", cast_psql_time_to_arrow
))
The 3 slightly tricky bits are:
- Deciding whether to format an Arrow object as a date or a time (in adapt_arrow_to_psql()) — you may want to handle it differently, but since Arrow will parse times without dates as occurring on "0001-01-01", the simplest thing to do is assume a date with an early year (like 1900 or earlier) represents a time instead of a date (which allows round-tripping of times from PostgreSQL to Arrow and back).
- Handling PostgreSQL's special "-infinity" and "infinity" values when converting between PostgreSQL and Arrow dates (in adapt_arrow_to_psql() and cast_psql_date_to_arrow()) — Arrow.min and Arrow.max are the closest equivalents.
- Handling the 4 different time variants that PostgreSQL emits (in cast_psql_time_to_arrow()):
- "12:34:56" (no fractional seconds or time zone)
- "12:34:56.123456" (fractional seconds but no time zone)
- "12:34:56-07" (no fractional seconds but time zone)
- "12:34:56.123456-07" (fractional seconds and time zone)
With those mappings in place, you can now use Arrow objects natively with psycopg2:
import arrow
import psycopg2
def test_datetimes():
conn = psycopg2.connect(dbname="mydbname", user="myuser")
try:
cur = conn.cursor()
cur.execute("""
CREATE TABLE foo (
id SERIAL PRIMARY KEY,
dt TIMESTAMP,
dtz TIMESTAMP WITH TIME ZONE,
d DATE,
t TIME,
twtz TIME WITH TIME ZONE
)
""")
cur.execute(
"INSERT INTO foo (dt, dtz, d, t, twtz) VALUES (%s, %s, %s, %s, %s)",
(
arrow.get("2001-02-03 04:05:06"),
arrow.get("2001-02-03 04:05:06-07"),
arrow.get("2001-02-03"),
arrow.get("04:05:06", "HH:mm:ss"),
arrow.get("04:05:06-07", "HH:mm:ssZ"),
),
)
cur.execute("SELECT * FROM foo")
result = cur.fetchone()
assert result[1] == arrow.get("2001-02-03 04:05:06")
assert result[2] == arrow.get("2001-02-03 04:05:06-07")
assert result[3] == arrow.get("2001-02-03")
assert result[4] == arrow.get("04:05:06", "HH:mm:ss")
assert result[5] == arrow.get("04:05:06-07", "HH:mm:ssZ")
finally:
conn.rollback()
Or with the Peewee ORM, you can use Peewee's built-in date/time fields, and pass and receive Arrow objects to/from those fields:
import arrow
import peewee
import playhouse.postgres_ext
db = playhouse.postgres_ext.PostgresqlExtDatabase("mydbname", user="myuser")
class Foo(peewee.Model):
dt = peewee.DateTimeField(
default=arrow.utcnow,
constraints=[peewee.SQL("DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")],
)
dtz = playhouse.postgres_ext.DateTimeTZField(
default=arrow.utcnow,
constraints=[peewee.SQL("DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")],
)
d = peewee.DateField(
default=arrow.utcnow,
constraints=[peewee.SQL("DEFAULT (CURRENT_DATE AT TIME ZONE 'UTC')")],
)
t = peewee.TimeField(
default=lambda: arrow.utcnow().time(),
constraints=[peewee.SQL("DEFAULT (CURRENT_TIME AT TIME ZONE 'UTC')")],
)
class Meta:
database = db
def test_datetimes():
with db.transaction() as tx:
try:
Foo.create_table()
result = Foo.get_by_id(
Foo.create(
dt=arrow.get("2001-02-03 04:05:06"),
dtz=arrow.get("2001-02-03 04:05:06-07"),
d=arrow.get("2001-02-03"),
t=arrow.get("04:05:06", "HH:mm:ss"),
).id
)
assert result.dt == arrow.get("2001-02-03 04:05:06")
assert result.dtz == arrow.get("2001-02-03 04:05:06-07")
assert result.d == arrow.get("2001-02-03")
assert result.t == arrow.get("04:05:06", "HH:mm:ss")
finally:
tx.rollback()