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:
$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.
| Column | How it stores 60 chars | Survives an algorithm change | Verdict |
|---|---|---|---|
text | 60 bytes + varlena length header, no padding | Yes (argon2id, scrypt all fit) | Recommended default |
varchar(255) | identical to text, plus a length-check constraint | Yes | Equivalent, use if you want an explicit cap |
char(60) | blank-padded to 60, stripped on read, no speed gain | No (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):
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")):
... # matchNode.js (the bcrypt package):
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):
$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:
CREATE EXTENSION IF NOT EXISTS pgcrypto;Then crypt() plus gen_salt('bf', cost) produces a bcrypt hash, and crypt() again verifies it:
-- 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.
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
- For the MySQL version of this exact problem, see how to store a bcrypt hash in MySQL (where the
VARCHAR(255)vsCHAR(60)tradeoff actually matters). - For picking the MySQL column type for a password column more generally, see choosing the right MySQL password column type.
- For storing a hash you compute for integrity rather than passwords, see how to store a SHA-256 hash in MySQL (the binary-column sibling problem).
FAQ
See also
- Storing a bcrypt hash in MySQL, the sibling problem where
VARCHAR(255)vsCHAR(60)actually changes the answer. - Storing an argon2id hash in MySQL, the algorithm OWASP now reaches for first, which lives in the same kind of self-describing string column.
- Storing an MD5 hash in PostgreSQL, for the integrity-fingerprint case (and a reminder of why MD5 is wrong for a password).
- How WordPress stores passwords, a real-world look at a password hashing scheme that moved to bcrypt.
- Running PostgreSQL in Docker, to spin up a throwaway instance and try the
pgcryptocrypt()examples above. - MySQL field types and sizes, the wider reference for picking a column type when the data is not a password.
Sources
Authoritative references this article was fact-checked against.
- PostgreSQL Documentation: pgcrypto (crypt, gen_salt, bf default cost 6)postgresql.org
- PostgreSQL Documentation: Character Types (text vs varchar storage)postgresql.org
- bcrypt: modular crypt format, $2a$/$2b$/$2y$ prefixes, 72-byte input limiten.wikipedia.org
- OWASP: Password Storage Cheat Sheet (argon2id first, bcrypt, work factors)cheatsheetseries.owasp.org





