Saving date and time in our database can be tricky, but it’s crucial to do it correctly. In this article, we address this problem using PostgreSQL and Kysely. We also explore time zones and how to handle them when designing our database.
Dates in PostgreSQL
We can determine how our database handles dates by examining the DateStyle parameter.
By default, DateStyle is set to ISO, MDY. To learn about other options, take a look at the official documentation.
The result of the query above includes two parts:
- the default date and time output,
- instructions on how to understand the input.
By default, PostgreSQL represents dates following the ISO 8601 standard, which means the default display format is YYYY-MM-DD.
The DateStyle parameter also tells us that PostgreSQL interprets given dates as month-day-year (MDY).
Because the input format is set to MDY, PostgreSQL treats the first number as the month and the second as the day. Ignoring this can lead to errors when entering our data.
Date columns built into PostgreSQL
There are various column types to pick from when describing the date and time.
DATE
The most straightforward column type we can choose is DATE.
1import { Kysely } from 'kysely';
2
3export async function up(database: Kysely<unknown>): Promise<void> {
4 await database.schema
5 .alterTable('articles')
6 .addColumn('created_at', 'date')
7 .execute();
8}
9
10export async function down(database: Kysely<unknown>): Promise<void> {
11 await database.schema.alterTable('articles').dropColumn('created_at');
12}This column allows us to store the date without the time. When we retrieve the data from the database, it is an instance of the Date class.
1import { Generated } from 'kysely';
2
3export interface ArticlesTable {
4 id: Generated<number>;
5 title: string;
6 paragraphs: string[];
7 author_id: number;
8 created_at?: Date;
9}In the dates retrieved from the database, the time is set to 00:00:00.
1import { Database } from '../database/database';
2import { Article } from './article.model';
3import { Injectable } from '@nestjs/common';
4
5@Injectable()
6export class ArticlesRepository {
7 constructor(private readonly database: Database) {}
8
9 async getById(id: number) {
10 const databaseResponse = await this.database
11 .selectFrom('articles')
12 .where('id', '=', id)
13 .selectAll()
14 .executeTakeFirst();
15
16 if (databaseResponse) {
17 console.log(databaseResponse.created_at instanceof Date); // true
18 // Sun Nov 05 2023 00:00:00 GMT+0100 (Central European Standard Time)
19 console.log(databaseResponse.created_at?.toString());
20 return new Article(databaseResponse);
21 }
22 }
23
24 // ...
25}TIME
If we want to store the time without the date, we can use the TIME column.
1import { Kysely } from 'kysely';
2
3export async function up(database: Kysely<unknown>): Promise<void> {
4 await database.schema
5 .alterTable('articles')
6 .addColumn('created_at', 'time')
7 .execute();
8}
9
10export async function down(database: Kysely<unknown>): Promise<void> {
11 await database.schema.alterTable('articles').dropColumn('created_at');
12}When using the TIME column, the retrieved data is a string.
1import { Generated } from 'kysely';
2
3export interface ArticlesTable {
4 id: Generated<number>;
5 title: string;
6 paragraphs: string[];
7 author_id: number;
8 created_at?: string;
9}We can investigate that in our repository.
1import { Database } from '../database/database';
2import { Article } from './article.model';
3import { Injectable } from '@nestjs/common';
4
5@Injectable()
6export class ArticlesRepository {
7 constructor(private readonly database: Database) {}
8
9 async getById(id: number) {
10 const databaseResponse = await this.database
11 .selectFrom('articles')
12 .where('id', '=', id)
13 .selectAll()
14 .executeTakeFirst();
15
16 if (databaseResponse) {
17 console.log(typeof databaseResponse.created_at); // string
18 console.log(databaseResponse.created_at); // 13:15
19 return new Article(databaseResponse);
20 }
21 }
22
23 // ...
24}TIMESTAMP
Another important date column is the TIMESTAMP.
1import { Kysely } from 'kysely';
2
3export async function up(database: Kysely<unknown>): Promise<void> {
4 await database.schema
5 .alterTable('articles')
6 .addColumn('created_at', 'timestamp')
7 .execute();
8}
9
10export async function down(database: Kysely<unknown>): Promise<void> {
11 await database.schema.alterTable('articles').dropColumn('created_at');
12}PostgreSQL stores the timestamp as a numeric value representing a specific moment in time. The way it’s displayed can be influenced by the DateStyle parameter. Since the default is set to ISO, MDY, PostgreSQL displays the date in the ISO format.
When using the TIMESTAMP column, the retrieved data is an instance of the Date class.
1import { Generated } from 'kysely';
2
3export interface ArticlesTable {
4 id: Generated<number>;
5 title: string;
6 paragraphs: string[];
7 author_id: number;
8 created_at?: Date;
9}The data that comes from the database includes both the date and the time.
1import { Database } from '../database/database';
2import { Article } from './article.model';
3import { Injectable } from '@nestjs/common';
4
5@Injectable()
6export class ArticlesRepository {
7 constructor(private readonly database: Database) {}
8
9 async getById(id: number) {
10 const databaseResponse = await this.database
11 .selectFrom('articles')
12 .where('id', '=', id)
13 .selectAll()
14 .executeTakeFirst();
15
16 if (databaseResponse) {
17 console.log(databaseResponse.created_at instanceof Date); // true
18 // Sun Nov 05 2023 15:30:34 GMT+0100 (Central European Standard Time)
19 console.log(databaseResponse.created_at?.toString());
20 return new Article(databaseResponse);
21 }
22 }
23
24 // ...
25}Timezones
The Coordinated Universal Time (UTC) is the primary time standard determined by atomic clocks. Timezones are usually defined by the number of hours ahead or behind UTC.
For instance, Eastern Standard Time (EST) can be expressed as UTC -5. So, if the current UTC is 20:00, the time in New York would be 15:00.
Dealing with time zones can be challenging, as they are linked to geography and politics and can be influenced by daylight saving adjustments. This video offers a great overview of various factors to keep in mind.
PostgreSQL didn’t perform any timezone-related conversions when we used the TIME and TIMESTAMP columns in this article. This means that when we input a specific date into our database, it will remain the same, regardless of the timezone we later use to display it.
Both of these types have counterparts that take time zones into account. When we use the TIMESTAMPTZ data type, we can provide the timezone and date. PostgreSQL then converts our input and stores it as UTC.
The official PostgreSQL documentation advises against using the TIMETZ type, which represents time with a timezone. Without date information, it would not be possible to account for daylight-saving time changes.
When we provide a timestamp labeled as Eastern Standard Time, PostgreSQL adds 5 hours before saving it. This ensures that our database remains consistent, and our time stays accurate, even if we input data using different timezones.
Let’s add the scheduled_date column to our articles and use the TIMESTAMPTZ column type.
1import { Kysely } from 'kysely';
2
3export async function up(database: Kysely<unknown>): Promise<void> {
4 await database.schema
5 .alterTable('articles')
6 .addColumn('scheduled_date', 'timestamptz')
7 .execute();
8}
9
10export async function down(database: Kysely<unknown>): Promise<void> {
11 await database.schema.alterTable('articles').dropColumn('scheduled_date');
12}Inserting the date into the database
To ensure the validity of the data our users provide, we can use the class-validator library. Kysely accepts data in either the Date class or as an ISO string. Let’s require the users to provide the ISO strings.
1import {
2 IsString,
3 IsNotEmpty,
4 IsOptional,
5 IsNumber,
6 IsISO8601,
7} from 'class-validator';
8
9export class ArticleDto {
10 @IsString()
11 @IsNotEmpty()
12 title: string;
13
14 @IsString({ each: true })
15 @IsNotEmpty({ each: true })
16 paragraphs: string[];
17
18 @IsOptional()
19 @IsNumber({}, { each: true })
20 categoryIds?: number[];
21
22 @IsISO8601({
23 strict: true,
24 })
25 @IsOptional()
26 scheduledDate?: string;
27}The strict parameter ensures that the given date is valid, taking into account factors such as leap days.
Since we allow the users to provide the date as an ISO string, but retrieve it from the database using the Date class, we should use the ColumnType generic type.
1import { ColumnType, Generated } from 'kysely';
2
3export interface ArticlesTable {
4 id: Generated<number>;
5 title: string;
6 paragraphs: string[];
7 author_id: number;
8 created_at?: Date;
9 scheduled_date?: ColumnType<Date, string | Date, string | Date>;
10}The next step is to adjust our model.
1export interface ArticleModelData {
2 id: number;
3 title: string;
4 paragraphs: string[];
5 author_id: number;
6 scheduled_date?: Date;
7}
8
9export class Article {
10 id: number;
11 title: string;
12 paragraphs: string[];
13 authorId: number;
14 scheduledDate?: Date;
15 constructor({
16 id,
17 title,
18 paragraphs,
19 author_id,
20 scheduled_date,
21 }: ArticleModelData) {
22 this.id = id;
23 this.title = title;
24 this.paragraphs = paragraphs;
25 this.authorId = author_id;
26 if (scheduled_date) {
27 this.scheduledDate = scheduled_date;
28 }
29 }
30}We can now use our new property when creating the articles.
1import { Database } from '../database/database';
2import { Article } from './article.model';
3import { BadRequestException, Injectable } from '@nestjs/common';
4import { ArticleDto } from './dto/article.dto';
5import { PostgresErrorCode } from '../database/postgresErrorCode.enum';
6import { isDatabaseError } from '../types/databaseError';
7
8@Injectable()
9export class ArticlesRepository {
10 constructor(private readonly database: Database) {}
11
12 async create(data: ArticleDto, authorId: number) {
13 try {
14 const databaseResponse = await this.database
15 .insertInto('articles')
16 .values({
17 title: data.title,
18 paragraphs: data.paragraphs,
19 author_id: authorId,
20 scheduled_date: data.scheduledDate,
21 })
22 .returningAll()
23 .executeTakeFirstOrThrow();
24 return new Article(databaseResponse);
25 } catch (error) {
26 if (!isDatabaseError(error)) {
27 throw error;
28 }
29 if (error.code === PostgresErrorCode.CheckViolation) {
30 throw new BadRequestException(
31 'The length of the content needs to be greater than 0',
32 );
33 }
34 if (error.code === PostgresErrorCode.NotNullViolation) {
35 throw new BadRequestException(
36 `A null value can't be set for the ${error.column} column`,
37 );
38 }
39 throw error;
40 }
41 }
42
43 // ...
44}Default values
We do not always have to provide the date manually. Instead, let’s adjust our migration that adds the created_at column.
1import { Kysely, sql } from 'kysely';
2
3export async function up(database: Kysely<unknown>): Promise<void> {
4 await database.schema
5 .alterTable('articles')
6 .addColumn('created_at', 'timestamptz', (column) => {
7 return column.notNull().defaultTo(sql`now()`);
8 })
9 .execute();
10}
11
12export async function down(database: Kysely<unknown>): Promise<void> {
13 await database.schema.alterTable('articles').dropColumn('created_at');
14}Above, we use the now() function built into PostgreSQL to provide the default value for the created_at column. Since we provide a default value, we can also make this column non-nullable and don’t worry about the articles we already have in the database.
Summary
In this article, we’ve explored different methods for storing date and time data with PostgreSQL. We’ve also learned how to use Kysely to define various types of date columns in our schema. Besides that, we’ve used the class-validator library to validate the dates provided through the API.
Timezones can be the source of various bugs and problems. By utilizing the timestamp with timezone type in PostgreSQL, we can maintain data consistency, regardless of the time zones in which our users provide the data. Thanks to that, we can minimize the chance of timezone-related issues.