A bcrypt hash is always exactly 60 characters (a fixed-format ASCII string), so it fits in CHAR(60). But the column you actually want for a password is VARCHAR(255): it stores the 60-character hash today and survives a later move to argon2id or scrypt without a schema change, because the encoded hash carries its own algorithm, cost, and salt inline. You compute the hash in your application (PHP password_hash, Python bcrypt, Node bcrypt), never with a MySQL function, and store the finished string. Below is the format breakdown, the column comparison, the app-side code, and a working users schema.
Short answer: password_hash VARCHAR(255) NOT NULL. Hash in the app with a cost of 10–12, store the 60-char result, look the user up by email, then verify the hash in your code. CHAR(60) is the exact-fit alternative if you are certain you will never change algorithm. Whatever you do, do not run MD5() or SHA2() on a password in SQL, that is the wrong tool for credentials.
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:
$2y$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 $2y$ 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). 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 exactly why the column-type decision is simpler than it looks.
The column: VARCHAR(255) vs CHAR(60)
Both fit a bcrypt hash. The question is what happens later.
| Column | Bytes per row | Fits bcrypt's 60 chars | Survives an algorithm change | Verdict |
|---|---|---|---|---|
VARCHAR(255) | up to 61 (60 + 1-byte length prefix) | Yes | Yes (argon2id, scrypt all fit) | Recommended default |
CHAR(60) | exactly 60 | Yes, exactly | No (argon2id hashes are ~95+ chars) | Exact-fit, only if algorithm is frozen |
VARCHAR(60) | up to 61 | Yes | No | No reason to pick this |
I reach for VARCHAR(255) as the default, and most frameworks pick a generously-sized string column for the same reason (Laravel's default password column and a Rails string column both land on VARCHAR(255); Django sizes its field at 128, which is the same idea with a tighter ceiling). The reasoning is migration safety. A bcrypt hash is 60 characters, but an argon2id hash in the same modular-crypt format runs around 95 to 100 characters, and scrypt is similar. If you pin the column at CHAR(60) and later follow OWASP's advice to move to argon2id, you are now doing an ALTER TABLE on your users table (a locking operation on older MySQL, and a coordination headache on a live system) before you can store a single new-format hash. VARCHAR(255) absorbs that change for free. The column never has to know which algorithm produced the string, because the string says so itself.
CHAR(60) is not wrong. It stores the hash exactly, it is one byte smaller per row than VARCHAR, and on a frozen schema it is perfectly idiomatic. The older canonical advice was CHAR(60) BINARY (or BINARY(60)) precisely because bcrypt's base64-ish alphabet is case-sensitive and MySQL's default collations (utf8mb4_0900_ai_ci on 8.0, latin1_swedish_ci historically) compare case-insensitively, so a BINARY column or a _bin collation guaranteed byte-exact comparison. That only matters if you compare the hash in SQL, which you should not do (see the worked schema below): you fetch the row by email and let the verify function do the byte-exact match in your app, so the column collation never enters into it. I only reach for CHAR(60) when something genuinely fixes the format (a spec, a compliance constraint, an existing column I am matching), and "I will never change my password hashing algorithm" is a promise that ages badly against the byte you save being rounding error on a users table.
One thing that does not drive this decision: the character set. A bcrypt hash is pure ASCII, so the value costs the same bytes in any charset. But VARCHAR(255) in utf8mb4 reserves up to 1020 bytes of index budget (255 × 4) if you ever index the column, which can blow the InnoDB index key-length limit when combined with other columns. In practice you almost never index the hash itself, because you look users up by username or email and then verify the hash in your application. If you do have a reason to index it, declare the column CHARACTER SET ascii (or latin1) so 255 chars cost 255 index bytes, not 1020. For the broader sizing rules, see MySQL data types and sizes.
Computing and verifying the hash in your app
The hash is computed in application code, never in SQL. MySQL has no bcrypt function, and even if it did you would not want the plaintext password traveling into a query, sitting in the query log, or being hashed by the database. Your app hashes the password and hands MySQL a finished 60-character string.
PHP (the password_hash API is the canonical way, and bcrypt is its default algorithm):
// Hash on signup. PASSWORD_BCRYPT pins bcrypt; PASSWORD_DEFAULT is also bcrypt today.
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
// $hash is a 60-char string like $2y$12$R9h/cIPz0gi... -> store it as-is.
// Verify on login.
if (password_verify($password, $hashFromDb)) {
// password matches
}Python (the bcrypt package):
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);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 is still 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 all three cases, you never supply it.
There is one bcrypt-specific gotcha worth knowing: bcrypt only looks at the first 72 bytes of the password. Anything past byte 72 is silently ignored, so two passwords that share a 72-byte prefix hash to the same value. For normal passwords this never comes up, but if you let users paste a passphrase or you pre-hash long inputs, be aware of the cutoff. (This is one of the reasons OWASP now nudges new projects toward argon2id, which has no such limit.)
A worked schema
Here is a minimal users table. The hash column is VARCHAR(255), the lookup key is email (unique, indexed), and verification happens in the app.
CREATE TABLE users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_users_email (email)
) ENGINE=InnoDB;
-- signup: the app computed the 60-char bcrypt hash, you just store it
INSERT INTO users (email, password_hash)
VALUES ('a@example.com', '$2y$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 = ?;The login flow is two steps: pull the row by email (which is what the index is for), then call password_verify / checkpw / compare in your code against the stored hash. You never write WHERE password_hash = ?, 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 SQL's.
This is identical on MariaDB. There is nothing MySQL-specific here: password_hash is just a VARCHAR holding a string your application produced, so MariaDB stores and indexes it exactly the same way. The schema above runs unchanged on either engine.
bcrypt vs argon2id vs scrypt
All three are deliberately slow, salted, memory-or-CPU-hard password hashes, and all three encode their parameters inline so all three live happily in VARCHAR(255).
| Algorithm | Hash length (encoded) | Resists | Pick it when |
|---|---|---|---|
| argon2id | ~95–100 chars | GPU and ASIC cracking (memory-hard) | New projects (OWASP's first choice) |
| bcrypt | 60 chars | GPU cracking (well-understood, everywhere) | Existing systems, broad library support, 72-byte inputs are fine |
| scrypt | ~80+ chars | GPU and ASIC cracking (memory-hard) | You already use it, or a platform mandates it |
For a brand-new system, argon2id is the current OWASP recommendation, and storing an argon2id hash in MySQL walks through its parameters. But bcrypt remains a completely defensible choice: it is battle-tested, available in every language, and its main practical limit (72 bytes) rarely bites real passwords. The point for this article is that the column is the same either way. Store the encoded string in VARCHAR(255) and the algorithm choice stays a code decision, not a schema one. If you want only the schema answer, the right password column type in MySQL covers it on its own.
What to do next
- For why hashing a password with
MD5()orSHA2()in SQL is wrong, see when MD5 is the wrong choice. - For storing a hash you compute for integrity rather than passwords, see how to store a SHA-256 hash in MySQL (the
BINARY(32)sibling problem). - For the modern, memory-hard alternative to bcrypt and why its hash length varies, see storing an Argon2id password hash in MySQL.
- For the column-type decision in the abstract (why
VARCHAR(255)survives an algorithm change), see the right password column type in MySQL. - For exact byte sizes of every MySQL column type, see MySQL data types and sizes.
- Doing the same on Postgres? The column choice and the
pgcrypto-vs-app-side question are covered in storing a bcrypt hash in PostgreSQL.
FAQ
See also
- Storing a bcrypt hash in PostgreSQL for the same decision on Postgres, plus the
pgcrypto-vs-app-side question. - The argon2id password hash in MySQL when you want the memory-hard algorithm OWASP now recommends for new builds.
- Choosing the right password column type in MySQL for the schema answer on its own, independent of algorithm.
- How WordPress stores passwords for a real-world look at bcrypt and phpass in a shipping codebase.
- The MySQL cheat sheet for the everyday
CREATE TABLE, index, and column-definition syntax around this. - Running MySQL in Docker to spin up a throwaway instance and test the schema before it touches production.
Sources
Authoritative references this article was fact-checked against.
- PHP Manual: password_hash() (bcrypt, PASSWORD_BCRYPT, cost, 72-byte limit)php.net
- PHP Manual: password_verify()php.net
- 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
- MySQL 8.0 Reference Manual: The CHAR and VARCHAR Typesdev.mysql.com





