TechEarl

How to Store a bcrypt Password Hash in PostgreSQL

A bcrypt hash is a fixed 60-character string. In PostgreSQL the right column is text (varchar(255) is equivalent). Why text over char(60), app-side hashing, the pgcrypto crypt() option, and a worked users schema.

Ishan Karunaratne⏱️ 12 min readUpdated
Share thisCopied
How to store a bcrypt password hash in PostgreSQL: the 60-character modular-crypt format, why text beats char(60), app-side hashing, the pgcrypto crypt() and gen_salt('bf') option, and a worked users-table schema.

A bcrypt hash is always exactly 60 characters in a fixed ASCII format, so the only real question in PostgreSQL is which character column to put it in. The idiomatic answer is text. In Postgres text and varchar(255) store a string identically (both are varlena, both use only the bytes the value needs), so the varchar(n) length is a constraint, not a storage optimization. You compute the hash in your application (Python bcrypt, Node bcrypt, PHP password_hash) and store the finished string, or you let Postgres compute it natively with the pgcrypto extension's crypt(). Below is the format, the column comparison, app-side code, the pgcrypto option, and a working users schema.

Short answer: password_hash text NOT NULL. varchar(255) is exactly equivalent if you prefer an explicit cap. Hash in the app with a cost of 10 to 12, store the 60-char result, look the user up by email, then verify in code. If you want Postgres to do the hashing, CREATE EXTENSION pgcrypto and use crypt(password, gen_salt('bf', 12)). Do not use md5() or any fast digest for a password.

What a bcrypt hash looks like

A bcrypt hash is a single fixed-length string in the modular crypt format. It is always 60 ASCII characters, broken down like this:

code
$2a$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
└┬┘ └┬┘ └────────────────────┬─────────────────────────────┘
 │   │                        │
 │   │                        └─ 22-char salt + 31-char hash (53 chars)
 │   └─ cost factor (work factor, here 12 → 2^12 rounds)
 └─ algorithm version prefix

Add it up: the prefix $2a$ is 4 characters, the two-digit cost plus its trailing $ is 3, and the salt-plus-digest tail is a base64-ish blob of 53. That is 60, every time, regardless of how long the password was.

The version prefix is one of $2a$, $2b$, or $2y$. They differ only in historical bug-compatibility details ($2y$ came out of PHP's fix for an early sign-extension bug, $2b$ is the OpenBSD canonical form, and pgcrypto's crypt() emits $2a$). All three are 4 characters and all three verify against the same library, so the 60-character length never changes between them. The cost is a two-digit number, usually 10, 11, or 12, and it controls how many rounds the key-derivation runs (2 to the power of the cost). Higher cost is slower, which is the whole point for a password hash.

Because the algorithm, the cost, and the salt are all encoded inside the string, you store nothing else. No separate salt column, no separate algorithm column. The hash is self-describing, which is what makes the column choice so simple.

The column: text vs varchar(255) vs char(60)

Here is where PostgreSQL differs from MySQL. In MySQL the convention is VARCHAR(255) and the CHAR(60) exact-fit is a real storage decision. In Postgres, text and varchar(n) are the same type under the hood with the same on-disk representation. There is no performance or storage penalty for text, and char(n) is actually the worst of the three because it blank-pads to the fixed width.

ColumnHow it stores 60 charsSurvives an algorithm changeVerdict
text60 bytes + varlena length header, no paddingYes (argon2id, scrypt all fit)Recommended default
varchar(255)identical to text, plus a length-check constraintYesEquivalent, use if you want an explicit cap
char(60)blank-padded to 60, stripped on read, no speed gainNo (argon2id is ~95+ chars)Avoid in Postgres

I reach for text. The PostgreSQL manual is explicit that there is no performance difference between text and varchar(n) (the length-limited variants cost a tiny extra check when storing), so the (n) buys you a constraint and nothing else. If you like the documentation value of a cap, varchar(255) is perfectly fine and leaves headroom for a future argon2id hash (~95 to 100 chars) without an ALTER TABLE. The two are interchangeable here.

char(60) is the one to avoid, and for a Postgres-specific reason. Unlike MySQL, where CHAR(60) is a tidy exact fit, Postgres char(n) is blank-padded: it pads the stored value out to the declared length with trailing spaces, then strips them on read. A bcrypt hash is exactly 60 characters so the padding is zero in this case, but you get no storage saving and no speed benefit over text, plus char(n) has surprising trailing-space comparison semantics. The Postgres docs themselves note char(n) has no performance advantage and is usually the slowest of the three. There is simply no reason to pick it.

This is the headline difference from the MySQL twin: storing a bcrypt hash in MySQL is a VARCHAR(255) vs CHAR(60) tradeoff, but in Postgres the answer collapses to "just use text."

Computing and verifying the hash in your app

For most applications, hash the password in application code and hand Postgres the finished 60-character string. This keeps the plaintext password out of your SQL statements, out of log_statement = 'all' logs, out of pg_stat_statements, and off the wire as a query parameter. It also keeps you portable: the same hashing code works if you later move databases.

Python (the bcrypt package, or passlib):

python
import bcrypt

# Hash on signup. gensalt(rounds=12) sets the cost factor.
hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds=12))
# hashed is 60 bytes; decode to store the str, or store the bytes.

# Verify on login.
if bcrypt.checkpw(password.encode("utf-8"), hash_from_db.encode("utf-8")):
    ...  # match

Node.js (the bcrypt package):

javascript
const bcrypt = require("bcrypt");

// Hash on signup. The second arg is the cost (salt rounds).
const hash = await bcrypt.hash(password, 12);

// Verify on login.
const ok = await bcrypt.compare(password, hashFromDb);

PHP (the password_hash API, where bcrypt is the default algorithm):

php
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
// 60-char string like $2y$12$R9h/cIPz0gi...  -> store it as-is.

if (password_verify($password, $hashFromDb)) {
    // password matches
}

A cost of 10 to 12 is the normal range. 10 is the common library default, 12 is a reasonable choice on modern server hardware where the extra time per login stays well under a tenth of a second. Pick the highest cost your login latency budget tolerates, then revisit it as hardware gets faster. The salt is generated for you in every case, you never supply it.

One bcrypt-specific gotcha: bcrypt only processes the first 72 bytes of the password. Anything past byte 72 is silently ignored, so two passwords sharing a 72-byte prefix hash to the same value. Normal passwords never reach this, but long passphrases or pre-hashed inputs can. (It is one of the reasons OWASP now points new projects at argon2id, which has no such cap.)

The native option: pgcrypto's crypt()

PostgreSQL can also compute bcrypt in the database through the pgcrypto extension. This is a genuine, well-supported Postgres feature, unlike MySQL where there is no bcrypt function at all. Enable it once per database:

sql
CREATE EXTENSION IF NOT EXISTS pgcrypto;

Then crypt() plus gen_salt('bf', cost) produces a bcrypt hash, and crypt() again verifies it:

sql
-- hash on signup: gen_salt('bf', 12) sets the cost factor
INSERT INTO users (email, password_hash)
VALUES ('a@example.com', crypt('s3cr3t', gen_salt('bf', 12)));

-- verify on login: re-crypt the input with the stored hash as the salt,
-- and compare to the stored hash
SELECT id
FROM users
WHERE email = 'a@example.com'
  AND password_hash = crypt('s3cr3t', password_hash);

The verify trick is the standard crypt(3) idiom: passing the stored hash as the second argument to crypt() reuses its embedded salt and cost, so the recomputed hash matches the stored one if and only if the password is right.

Mind the default cost. gen_salt('bf') with no second argument defaults to a cost of 6, which is far too low for a password in 2026. Always pass an explicit work factor: gen_salt('bf', 12). The bf cost accepts 4 to 31, but only 10 to 12 are sensible for live logins (anything above ~14 starts to hurt login latency noticeably). pgcrypto emits the $2a$ variant.

The one real caveat with the in-DB approach: the plaintext password now appears in the SQL statement, so it can land in your Postgres logs (log_statement, log_min_duration_statement, error logs on a failed query) and in pg_stat_statements unless you are careful to parameterize it and keep statement logging off for these queries. Always pass the password as a bound parameter, never string-interpolated into the SQL. If you cannot guarantee your logging configuration, hash in the app instead. I treat crypt() as the right tool when the database is the source of truth and you want to avoid an app-layer hashing dependency (stored procedures, database-first systems, quick admin scripts), and the application layer as the default for everything else.

A worked schema

A minimal users table. The hash column is text, the lookup key is email (unique, indexed), and verification happens in the app.

sql
CREATE TABLE users (
  id            bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email         text NOT NULL,
  password_hash text NOT NULL,
  created_at    timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT uq_users_email UNIQUE (email)
);

-- signup: the app computed the 60-char bcrypt hash, you just store it
INSERT INTO users (email, password_hash)
VALUES ('a@example.com', '$2a$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW');

-- login: fetch by email, then verify in the app (never compare hashes in SQL)
SELECT id, password_hash FROM users WHERE email = $1;

The app-side login flow is two steps: pull the row by email (which is what the unique index is for), then call checkpw / compare / password_verify in your code against the stored hash. You never write WHERE password_hash = $1, because bcrypt re-hashes with a random salt every time, so two hashes of the same password are different strings. Equality matching is the verify function's job, not a plain SQL comparison's.

If you go the pgcrypto route instead, the table is identical (password_hash text NOT NULL) and the login query is the crypt() comparison from the previous section. Either way the column never changes, because the stored string already encodes its own algorithm, cost, and salt.

Why never md5() for a password

Postgres ships an md5() function and it is tempting to reach for password_hash text DEFAULT md5(...). Do not. MD5 (and any fast general-purpose digest, including SHA-256) is built to be fast, which is exactly wrong for a credential: an attacker who steals the table can try billions of guesses per second on a GPU. MD5 is also unsalted as you would call it here, so identical passwords share a hash and rainbow tables apply directly. Passwords need a deliberately slow, salted, purpose-built hash: bcrypt, argon2id, or scrypt. The pgcrypto crypt() family is the slow kind, md5() is not.

For new systems argon2id is OWASP's first choice, and because it also encodes its parameters inline it lives in the same text column with no schema change. The point of this article is that the column decision is settled regardless of algorithm: store the encoded string in text and the algorithm choice stays a code decision, not a schema one.

What to do next

FAQ

See also

Sources

Authoritative references this article was fact-checked against.

TagsPostgreSQLbcryptPassword HashingpgcryptotextSchema DesignSecurity

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

How to Store an Argon2 Password Hash in MySQL

Store an Argon2id password hash in MySQL or MariaDB the right way: VARCHAR(255), never a fixed-width column. The encoded format, why its length varies, computing it in PHP / Python / Node, OWASP parameters, and a worked users schema.

How to Store an Array in PostgreSQL

PostgreSQL has native array types: any base type can be an array, declared with []. How to insert with the curly-brace literal or ARRAY[...], query with @>, &&, and ANY(), index with GIN, and when an array beats a junction table or jsonb.