CRUD and the four chain methods
Reading and writing your Postgres data through Drizzle's typed query builder, the create-read-update-delete operations every feature is built on.
In the last chapter you made the schema the source of truth. You have an invoices table, an organizations table, the relations between them, and every TypeScript row type derived from those definitions in a single line. That’s the whole shape of your data. Right now, though, it does nothing: a table you can’t read from or write to is furniture. This lesson is where the schema starts earning its keep.
Before writing a single query, it helps to step back and ask one question: given a schema, what is the smallest set of call shapes that covers most of a feature’s data access? The answer is reassuring, because almost every feature you’ll ever build does the same four things to its data. It reads rows. It filters, sorts, and pages through them. And it writes rows back. The surface is much smaller than it looks, and it has been the same four operations since SQL was invented: CRUD , meaning create, read, update, delete. SQL spells them INSERT, SELECT, UPDATE, DELETE. Drizzle wraps each one in a typed, autocompleted TypeScript call, because Drizzle is an ORM : it maps your tables to code instead of leaving you to concatenate SQL by hand.
By the end of this lesson you’ll be able to write every basic read and write a feature needs. You’ll know the four entry points (db.select, db.insert, db.update, db.delete) and the four methods that shape a result set (where, orderBy, limit, offset). You’ll learn the one omission that can erase an entire table in production. And you’ll see why SQL injection, one of the most damaging bugs in web history, is something you essentially never have to think about on these paths. We’ll start with the object that runs every one of these queries.
The db client and the query builder
Section titled “The db client and the query builder”Every query in your app goes through one object: db. You import it from db/index.ts, alongside the tables from db/schema.ts. We won’t build db/index.ts here, since wiring it up to your Neon database is its own setup chapter a little later, but you should picture it as the single call site for all data access. One import, one client, every read and write.
import { db } from '@/db';import { invoices } from '@/db/schema';One point surprises people first, so let’s get it out of the way. When you call db.select(), no query runs. Nothing touches the database. What you get back is a query builder, an object that’s collecting instructions. Each method you call on it (.from(), .where(), and the rest) adds another instruction and hands the builder right back to you, which is why the calls chain. The builder sits there, fully assembled but inert, until you do one specific thing: await it.
const allInvoices = await db.select().from(invoices);That await is the trigger. The builder is a thenable , an object you can await, and only when you await it (or call .execute()) does Drizzle assemble the SQL and send it to Postgres. This laziness buys you two things worth holding onto. First, nothing fires by accident: a builder you forget to await is a no-op, not a stray query against production. Second, the order you chain the methods in doesn’t change the SQL that comes out. .where().orderBy() and .orderBy().where() produce the identical query, because the builder collects the pieces and arranges them correctly no matter what order you hand them over. Pick an order that reads well and stop worrying about it.
With that mental model in place, we can read some rows.
Reading rows with db.select
Section titled “Reading rows with db.select”The simplest read is the whole table:
const allInvoices = await db.select().from(invoices);allInvoices is typed as Invoice[]: an array of the full row shape, every column, exactly the $inferSelect type you derived in the last chapter (Invoice is typeof invoices.$inferSelect). You didn’t annotate it. Drizzle reads the type straight off the invoices table and flows it through the query, so the result is typed for free and can never drift from the schema.
One detail trips up almost everyone the first time: .from() is mandatory. db.select() on its own doesn’t know what table you mean, so it reads as half a sentence. Always pair db.select() with .from(table).
Often you don’t want every column. A list view might need only the id and the amount. You narrow the read by passing a projection, an object that names exactly the columns you want:
const rows = await db .select({ id: invoices.id, amountDue: invoices.amountDue }) .from(invoices);Now rows is typed as { id: string; amountDue: string }[]: precisely those two fields, no more. The return type follows the projection. You didn’t reach for Pick<Invoice, 'id' | 'amountDue'> or write the shape out by hand; you picked the columns and the type came with them. This is the carve-out from the last chapter: projecting columns you already declared is fine, and the smell to avoid is restating a field and its type in a hand-written interface. In the projection object, the key on the left (id, amountDue) is the name the field gets in TypeScript, and the value on the right is the column it reads from.
Notice amountDue comes back as string, not number. That’s deliberate, and it’s the rule for the whole course: money is a numeric column, and numeric maps to string in TypeScript so you never lose a cent to floating-point rounding. You’ll format it for display and never run it through parseFloat. Hold that thought, because it matters the moment you write an amount back.
Now try it yourself, since the syntax sticks far better once you type it. In the exercise below, the database is already seeded with a handful of invoices for one organization. Write the query that returns just the id and amountDue for every invoice.
Return the id and amountDue of every invoice — nothing else.
View schema & seed rows
export const organizations = pgTable('organizations', {
id: integer('id').primaryKey(),
name: text('name').notNull(),
});
export const invoices = pgTable('invoices', {
id: integer('id').primaryKey(),
organizationId: integer('organization_id')
.references(() => organizations.id)
.notNull(),
amountDue: numeric('amount_due', { precision: 12, scale: 2 }).notNull(),
status: text('status').notNull(),
createdAt: timestamp('created_at').notNull(),
}); INSERT INTO organizations (id, name) VALUES (1, 'Acme'); INSERT INTO invoices (id, organization_id, amount_due, status, created_at) VALUES (1, 1, '120.00', 'draft', '2026-05-02 09:00Z'), (2, 1, '0.00', 'sent', '2026-05-05 09:00Z'), (3, 1, '450.50', 'paid', '2026-05-08 09:00Z'), (4, 1, '89.99', 'sent', '2026-05-11 09:00Z');
- Query returns the 4 expected rows (any order)
Reading every row is rare in real code, though. The next method is one you’ll write in almost every query you ever ship.
Filtering with where and the operator helpers
Section titled “Filtering with where and the operator helpers”A feature almost never wants all the invoices. It wants the sent ones, or the ones over a threshold, or the ones belonging to the organization the current user is looking at. That’s what where is for: it filters the rows before they come back.
But where doesn’t take a string. You don’t write where('status = sent'). You pass it a condition built from a small set of helper functions imported from drizzle-orm. The first one you’ll reach for is eq, which means equals:
const sentInvoices = await db .select() .from(invoices) .where(eq(invoices.status, 'sent'));Read eq(invoices.status, 'sent') as “the status column equals 'sent'.” The first argument is the column; the second is the value to compare it against. There’s a helper like this for every comparison you’ll want. You don’t need to memorize them, since they read exactly like what they emit, but here’s the working set so you know what’s available:
The last two rows hold a detail that surprises people new to Postgres: like is case-sensitive. like(invoices.status, 'SENT') will not match a stored 'sent'. When you want case-insensitive matching, which for user-facing search you almost always do, reach for ilike, the case-insensitive variant Postgres gives you natively:
where(ilike(organizations.name, '%acme%'))Combining conditions
Section titled “Combining conditions”One condition is rarely enough. You filter by status and organization, or by one status or another. You combine conditions with and(...), or(...), and not(...): each takes one or more conditions and joins them. Here’s the shape you’ll write more than any other in this entire course:
const orgSentInvoices = await db .select() .from(invoices) .where(and(eq(invoices.organizationId, orgId), eq(invoices.status, 'sent')));That reads as “sent invoices belonging to this organization,” and the eq(invoices.organizationId, orgId) part is more important than it looks. Your app is multi-tenant: many organizations share one database, so a query that forgets to scope by organizationId can leak one tenant’s invoices to another. You filter by it explicitly, at the query layer, every time.
Why SQL injection isn’t your problem here
Section titled “Why SQL injection isn’t your problem here”It’s worth looking closely at what just happened, because it gives you a major security property for free.
When you wrote eq(invoices.status, 'sent'), that 'sent' did not get glued into a SQL string. Drizzle sends the SQL text and the value to Postgres separately: the query goes out as ... WHERE status = $1, and 'sent' rides along as the bound value for $1. Postgres receives the structure and the data on different channels and never confuses one for the other. This is a parameterized query , and the $1 is a placeholder .
This matters because a value that is never part of the SQL text can never be interpreted as SQL. Suppose a user types '; DROP TABLE invoices; -- into a search box and that string lands in your eq. Postgres treats it as a literal value to compare against, an absurd string that matches no invoice, rather than as a command to run. That attack, where hostile input changes what a query means, is SQL injection , and it has caused some of the largest data breaches on record. Through Drizzle’s helpers it is structurally impossible, because there is no string for the attacker’s input to escape into.
To see that this is a property of the tool and not just good luck, compare the two ways you might write the same filter:
// the bad old way — build the SQL as a string, then run itconst query = `SELECT * FROM invoices WHERE status = '${userInput}'`;The injection. userInput is spliced straight into the SQL text. Type ' OR '1'='1 and the WHERE clause suddenly matches every row, and a more elaborate payload can reach the rest of your database. The string is the query, so the input becomes code.
await db.select().from(invoices).where(eq(invoices.status, userInput));The safe form. userInput is bound as $1 by the database driver, never concatenated. Whatever the user types is data, compared against the column, and it cannot become SQL. This is the default; you have to go out of your way to lose it.
There is exactly one way to lose this protection, and it’s worth naming so you recognize it in a code review: sql.raw(userInput) splices its argument into the query verbatim, exactly like the unsafe tab above. It exists for a narrow, legitimate purpose you’ll meet at the end of this chapter, and it is never pointed at user input. Everywhere else, parameterization is automatic and you can stop thinking about injection.
Time to compose a real filter yourself. The exercise below seeds invoices across two organizations with varied statuses and amounts. Return the sent invoices for organization 1 whose amountDue is above 100, which means an and(...) of three conditions: the org, the status, and the threshold.
Return every sent invoice for organization 1 whose amountDue is above 100 — keep the full row. (Above means strictly greater than 100, so an invoice sitting exactly at 100.00 does not qualify.)
View schema & seed rows
export const organizations = pgTable('organizations', {
id: integer('id').primaryKey(),
name: text('name').notNull(),
});
export const invoices = pgTable('invoices', {
id: integer('id').primaryKey(),
organizationId: integer('organization_id')
.references(() => organizations.id)
.notNull(),
amountDue: numeric('amount_due', { precision: 12, scale: 2 }).notNull(),
status: text('status').notNull(),
createdAt: timestamp('created_at').notNull(),
}); INSERT INTO organizations (id, name) VALUES (1, 'Acme'), (2, 'Globex'); INSERT INTO invoices (id, organization_id, amount_due, status, created_at) VALUES (1, 1, '80.00', 'sent', '2026-05-02 09:00Z'), (2, 1, '150.00', 'sent', '2026-05-05 09:00Z'), (3, 1, '200.00', 'draft', '2026-05-08 09:00Z'), (4, 1, '450.00', 'sent', '2026-05-11 09:00Z'), (5, 2, '120.00', 'sent', '2026-05-14 09:00Z'), (6, 1, '99.99', 'paid', '2026-05-17 09:00Z'), (7, 1, '100.00', 'sent', '2026-05-20 09:00Z');
- Query returns the 2 expected rows (any order)
You can now read rows and filter them. The last three methods don’t filter; they shape the result set you’ve already selected, setting its order and its size.
Sorting and paging with orderBy, limit, and offset
Section titled “Sorting and paging with orderBy, limit, and offset”Rows come back from Postgres in no guaranteed order unless you ask for one. orderBy is how you ask. You wrap the column in asc(...) or desc(...), both from drizzle-orm, to set the direction:
const recent = await db .select() .from(invoices) .where(eq(invoices.organizationId, orgId)) .orderBy(desc(invoices.createdAt));That gives you the newest invoices first. It looks complete, but a subtle bug hides in it, the kind that works on your laptop yet fails in production, so it’s worth slowing down for.
Always add a tiebreaker
Section titled “Always add a tiebreaker”createdAt is a timestamp, and timestamps collide. Two invoices created in the same millisecond, say during a bulk import or a burst of traffic, share a createdAt value. When you sort by createdAt alone, Postgres has no instruction for how to order rows that tie on that column, so it returns them in whatever order is convenient. That can be one way today and the other way tomorrow, after a row gets updated and moves on disk. Your sort is then non-deterministic, and you won’t notice until a user swears a row jumped position, or a paginated list shows the same invoice twice.
The fix is one extra key, a unique tiebreaker, almost always the primary key:
.orderBy(desc(invoices.createdAt), asc(invoices.id))Now ties on createdAt are broken by id, which is unique, so the order is fully determined, or deterministic: the same query returns the same rows in the same order every time. Get in the habit of pairing any non-unique sort column with the primary key as a tiebreaker. This is not just tidiness, either. Later in this chapter, cursor pagination depends on exactly this technique to page through a list without skipping or repeating rows.
Slicing the result with limit and offset
Section titled “Slicing the result with limit and offset”limit caps how many rows come back; offset skips rows from the front. Together they page:
.orderBy(desc(invoices.createdAt), asc(invoices.id)).limit(20).offset(40)That’s page three at twenty rows a page: skip the first forty, take the next twenty. It’s the most intuitive way to paginate, and for small, fixed lists, like an admin table of a few hundred rows, it’s exactly the right tool. It also has real limits at scale and on data that changes while a user reads it, and there’s a better technique for those cases. We’ll make that call properly in the cursor pagination lesson later in this chapter; for now, know that offset is the simple default and that it has a ceiling.
Two things are worth filing away, since this is the first time you’re chaining three methods at once. First, .limit(0) returns an empty array, which is different from leaving .limit off entirely, since that returns every row. Don’t reach for limit(0) thinking it means “no limit.” Second, the order you chain .where(), .orderBy(), and .limit() among themselves makes no difference to the SQL, just as the builder section explained. Order them however reads best, which for most people is filter, then sort, then slice.
Let’s lock in the tiebreaker habit while it’s fresh. Return the five most recent sent invoices for organization 1, newest first, with ties broken by id.
Return the five most recent sent invoices for organization 1 — newest first, with ties on createdAt broken by id (ascending). Note that invoices 7 and 8 share a createdAt, so the tiebreaker decides which comes first.
View schema & seed rows
export const organizations = pgTable('organizations', {
id: integer('id').primaryKey(),
name: text('name').notNull(),
});
export const invoices = pgTable('invoices', {
id: integer('id').primaryKey(),
organizationId: integer('organization_id')
.references(() => organizations.id)
.notNull(),
amountDue: numeric('amount_due', { precision: 12, scale: 2 }).notNull(),
status: text('status').notNull(),
createdAt: timestamp('created_at').notNull(),
}); INSERT INTO organizations (id, name) VALUES (1, 'Acme'), (2, 'Globex'); INSERT INTO invoices (id, organization_id, amount_due, status, created_at) VALUES (1, 1, '120.00', 'paid', '2026-05-08 09:00Z'), (2, 1, '150.00', 'sent', '2026-05-14 09:00Z'), (3, 1, '200.00', 'draft', '2026-05-30 09:00Z'), (4, 1, '450.00', 'sent', '2026-05-20 09:00Z'), (5, 2, '300.00', 'sent', '2026-05-29 09:00Z'), (6, 1, '99.99', 'sent', '2026-05-25 09:00Z'), (7, 1, '80.00', 'sent', '2026-05-28 09:00Z'), (8, 1, '60.00', 'sent', '2026-05-28 09:00Z'), (9, 1, '40.00', 'sent', '2026-05-10 09:00Z');
- Query returns the 5 expected rows in order
That covers reads: select, filter, sort, and page. The other half of CRUD is putting rows back.
Writing rows: insert, update, and delete
Section titled “Writing rows: insert, update, and delete”Writes share two threads, which is why we teach them together. Both threads deserve close attention, because a mistake in a write doesn’t just return the wrong data; it changes or destroys the data you already have.
Inserting uses db.insert(table).values(...). What you pass to .values() is exactly the $inferInsert shape from the last chapter, and that shape already encodes which columns you must supply and which you can skip. Columns with a default fill themselves in: createdAt has .defaultNow() and the primary key has its own default, so you leave them out. Columns marked .notNull() with no default are required. Generated columns are rejected outright. You don’t re-derive that asymmetry; the type already knows it.
await db.insert(invoices).values({ organizationId: orgId, amountDue: '0.00', status: 'draft', dueDate: '2026-07-01',});Notice amountDue is the string '0.00', not the number 0. It’s the same money rule as before: numeric is a string end to end, on the way in just as on the way out. To insert several rows at once, hand .values() an array instead of a single object; Drizzle batches them into one statement.
Updating uses db.update(table).set(...).where(...). .set() takes the columns you want to change:
await db.update(invoices).set({ status: 'paid' }).where(eq(invoices.id, id));Deleting uses db.delete(table).where(...):
await db.delete(invoices).where(eq(invoices.id, id));There’s one point here that carries more weight than the rest, so give it your full attention.
The missing where that empties the table
Section titled “The missing where that empties the table”Look at the update and the delete again. Both end in a .where(...). Take it away:
await db.update(invoices).set({ status: 'void' });await db.delete(invoices);The first sets every invoice in the table to 'void'. The second empties the table. Not for one organization, but for all of them, every row, gone.
What makes this dangerous rather than merely possible is that Drizzle does not warn you. The types check, the code compiles, and the statement runs without an error or a confirmation prompt. It is perfectly valid SQL, because an UPDATE or DELETE with no WHERE clause means “every row” by definition, so the database is behaving exactly as designed. The gap between “I meant to update one row” and “I updated all of them” is a single missing clause, and nothing in the type system is positioned to catch it.
So the habit to build is simple: every update and every delete carries a where. There are no exceptions in normal feature code, and on the rare occasion you genuinely mean “all rows,” say so explicitly in a comment. Because habits slip, the ecosystem backs you up. The eslint-plugin-drizzle package ships two lint rules, enforce-update-with-where and enforce-delete-with-where, that flag an unqualified update or delete at lint time, before the code ever runs. Turn them on, since the consequences are severe enough that you want a tool catching this as well as your own discipline.
Which of these statements changes more than one row?
await db.update(invoices).set({ status: 'paid' }).where(eq(invoices.id, id));await db.update(invoices).set({ status: 'void' });await db.delete(invoices).where(eq(invoices.id, id));where, so the set applies to every row in invoices. The other two are scoped by eq(invoices.id, id) and touch at most a single row. The absence of a where is the entire difference.Getting the written row back with .returning()
Section titled “Getting the written row back with .returning()”Every write raises a question: what did I just write? You inserted an invoice, so what id did it get? You updated one, so what does it look like now? The obvious answer is to issue a second query: insert, then select the row you just inserted. That means two round-trips to the database, with a window between them where another request could change things. There’s a better way, and it runs through the whole chapter.
Append .returning() to any insert, update, or delete and the statement hands the affected rows straight back with the full $inferSelect shape, or a projected subset if you pass a projection, just like select:
const [created] = await db .insert(invoices) .values({ organizationId: orgId, amountDue: '0.00', status: 'draft', dueDate: '2026-07-01' }) .returning();That is one statement. created is the row that landed in the table, carrying the full Invoice shape with its generated id, its createdAt, and everything else, all with no follow-up select. Note that .returning() hands back an array, so you destructure the one row out, just like a single-row read; there’s more on that pattern at the end of the lesson. Whenever you find yourself writing a row and then immediately selecting it back, that’s the signal to reach for .returning() instead. The two write shapes you’ll use most are worth walking through side by side.
const [created] = await db .insert(invoices) .values({ organizationId: orgId, amountDue: '120.00', status: 'draft', dueDate: '2026-07-01' }) .returning();
const [updated] = await db .update(invoices) .set({ status: 'paid' }) .where(eq(invoices.id, id)) .returning({ id: invoices.id, status: invoices.status });Insert a new draft invoice. .values() takes the $inferInsert shape, so amountDue is the string '120.00'; id and createdAt are left out because their defaults fill them in.
const [created] = await db .insert(invoices) .values({ organizationId: orgId, amountDue: '120.00', status: 'draft', dueDate: '2026-07-01' }) .returning();
const [updated] = await db .update(invoices) .set({ status: 'paid' }) .where(eq(invoices.id, id)) .returning({ id: invoices.id, status: invoices.status });.returning() hands the new row straight back, and destructuring [created] pulls it out of the returned array. One statement, with no follow-up select to learn the generated id.
const [created] = await db .insert(invoices) .values({ organizationId: orgId, amountDue: '120.00', status: 'draft', dueDate: '2026-07-01' }) .returning();
const [updated] = await db .update(invoices) .set({ status: 'paid' }) .where(eq(invoices.id, id)) .returning({ id: invoices.id, status: invoices.status });A second, separate write: mark an invoice paid. .set() lists only the columns that change, here just status.
const [created] = await db .insert(invoices) .values({ organizationId: orgId, amountDue: '120.00', status: 'draft', dueDate: '2026-07-01' }) .returning();
const [updated] = await db .update(invoices) .set({ status: 'paid' }) .where(eq(invoices.id, id)) .returning({ id: invoices.id, status: invoices.status });This where is the guard that keeps the update to exactly one row, the invoice whose id the caller passed in. Drop this single line and every invoice in the table is marked paid.
const [created] = await db .insert(invoices) .values({ organizationId: orgId, amountDue: '120.00', status: 'draft', dueDate: '2026-07-01' }) .returning();
const [updated] = await db .update(invoices) .set({ status: 'paid' }) .where(eq(invoices.id, id)) .returning({ id: invoices.id, status: invoices.status });A projected .returning(), just the two columns we care about, so each returned row is shaped { id: string; status: '…' }. updated reflects the row’s new state without a second read.
One more thing about deletes before you practice, because the common default might surprise you: in most SaaS apps you rarely hard-delete a row at all. The usual pattern is a soft delete, an update that sets a deletedAt timestamp instead of removing the row, so the record survives for audit and can be recovered. That deletedAt column already exists on your tables from the last chapter. A real delete is reserved for things like offboarding a whole tenant or expiring old audit logs. The next chapter covers soft delete and the query-time filtering it needs; for now, just know that db.delete removes data for good and isn’t your everyday reach.
Now write a row yourself and read it back in one statement. Mark invoice 1 as 'paid' with a correct where, and .returning() just its id and status.
Mark invoice 1 as paid, and return its id and status so the caller doesn't need a second query. The where is the guard — without it, every invoice in the table flips to paid.
View schema & seed rows
export const organizations = pgTable('organizations', {
id: integer('id').primaryKey(),
name: text('name').notNull(),
});
export const invoices = pgTable('invoices', {
id: integer('id').primaryKey(),
organizationId: integer('organization_id')
.references(() => organizations.id)
.notNull(),
amountDue: numeric('amount_due', { precision: 12, scale: 2 }).notNull(),
status: text('status').notNull(),
createdAt: timestamp('created_at').notNull(),
}); INSERT INTO organizations (id, name) VALUES (1, 'Acme'); INSERT INTO invoices (id, organization_id, amount_due, status, created_at) VALUES (1, 1, '120.00', 'sent', '2026-05-02 09:00Z'), (2, 1, '0.00', 'sent', '2026-05-05 09:00Z'), (3, 1, '450.50', 'sent', '2026-05-08 09:00Z'), (4, 1, '89.99', 'draft','2026-05-11 09:00Z');
- Query returns the 1 expected row (any order)
Reading one row vs. many
Section titled “Reading one row vs. many”You’ll run into this within your first hour: you have an invoice’s id, you want that one invoice, and db.select() insists on handing you an array. There’s no db.select().findOne(). The builder always returns rows, plural, even when you know there’s at most one.
The idiom is to cap the query at one row and destructure the first element out of the array:
const [invoice] = await db .select() .from(invoices) .where(eq(invoices.id, id)) .limit(1);invoice is now a single Invoice, or undefined if no row matched that id. That | undefined is not noise; it’s the type system reminding you the row might not exist. The not-found case is real (a deleted invoice, a bad id in a URL) and you’ll handle it downstream, but the type makes sure you can’t forget it’s possible.
If you went looking for a .findFirst() method on db.select() and didn’t find one, you weren’t wrong to look; it exists, just not here. findFirst lives on Drizzle’s relational query API (db.query.invoices.findFirst(...)), a separate, higher-level way to read that’s built for fetching a row together with its related rows. That’s the subject of a lesson coming up shortly in this chapter. On the plain SQL builder you’re using now, .limit(1) and a destructure is the move.
That’s the full toolkit: four entry points, four chain methods, the operator helpers, parameterization you get for free, .returning() to skip the round-trip, and the missing-where failure mode you now watch for in review. Every later lesson in this chapter, covering joins, nested reads, aggregates, upserts, and pagination, is built on these exact shapes. Get comfortable here and the rest is layering.
Keep these close
Section titled “Keep these close”The official Drizzle query docs are the reference you’ll come back to; bookmark them now.
The full select API: projections, where, orderBy, limit, offset.
values, batch inserts, and .returning().
set, where, and returning on the two mutation paths.
Every operator helper: eq, gt, inArray, ilike, and the rest.
External resources
Section titled “External resources”The injection section is the one idea here worth experiencing directly. These two interactive labs let you run the attack yourself and watch input become code, which makes the reason Drizzle’s parameterization matters much clearer.