<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Israa</title>
    <description>The latest articles on DEV Community by Israa (@israay).</description>
    <link>https://dev.to/israay</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4190579%2F7ee54cae-1c55-432c-a918-064785606edc.png</url>
      <title>DEV Community: Israa</title>
      <link>https://dev.to/israay</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/israay"/>
    <language>en</language>
    <item>
      <title>Offset vs cursor pagination in SQL</title>
      <dc:creator>Israa</dc:creator>
      <pubDate>Tue, 10 Nov 2026 17:33:20 +0000</pubDate>
      <link>https://dev.to/israay/offset-vs-cursor-pagination-in-sql-56hh</link>
      <guid>https://dev.to/israay/offset-vs-cursor-pagination-in-sql-56hh</guid>
      <description>&lt;p&gt;Pagination means splitting a long list into smaller pages, like showing 10 posts at a time instead of every row in the table. It's a favorite backend interview topic, and the question usually sounds like this: offset or cursor, and why?&lt;/p&gt;

&lt;p&gt;Every example here is plain SQL for PostgreSQL 18, the current version. You can run it in psql, the command-line tool that comes with Postgres, or in any SQL editor. You'll fill a table with 100,000 posts, watch offset pagination slow down and repeat rows, then fix both problems with a cursor. It takes about 10 minutes to read and try.&lt;/p&gt;

&lt;p&gt;Offset pagination says "skip 20 rows and give me the next 10," while cursor pagination says "give me the 10 rows after this one."&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Make a table with 100,000 posts
&lt;/h2&gt;

&lt;p&gt;Start with one &lt;code&gt;posts&lt;/code&gt; table and plenty of sample rows, so the slow queries actually show up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;         &lt;span class="kt"&gt;integer&lt;/span&gt; &lt;span class="no"&gt;GENERATED&lt;/span&gt; &lt;span class="no"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="no"&gt;IDENTITY&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;title&lt;/span&gt;      &lt;span class="kt"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="kt"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="nb"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'Post '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;timestamptz&lt;/span&gt; &lt;span class="s1"&gt;'2026-01-01 00:00+00'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="kt"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'1 minute'&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nf"&gt;generate_series&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;GENERATED ALWAYS AS IDENTITY&lt;/code&gt; makes Postgres number each new row, so a bigger id always means a newer post, and &lt;code&gt;timestamptz&lt;/code&gt; is a date and time with a time zone. A primary key is the column that identifies each row, and Postgres builds an index for it automatically: a sorted copy of the ids it can search quickly, like the index at the back of a book. &lt;code&gt;generate_series(1, 100000)&lt;/code&gt; produces the numbers 1 to 100,000, so the &lt;code&gt;INSERT&lt;/code&gt; adds one post per number, each a minute after the last.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Offset pagination
&lt;/h2&gt;

&lt;p&gt;Offset pagination is the version most tutorials show. The app takes a page number, works out how many rows to skip, and puts that number in the query.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- page 3, 10 posts per page: skip (3 - 1) * 10 = 20 rows&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="k"&gt;OFFSET&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ORDER BY id DESC&lt;/code&gt; puts the newest posts first, &lt;code&gt;LIMIT&lt;/code&gt; caps how many rows come back, and &lt;code&gt;OFFSET&lt;/code&gt; says how many to skip, so this returns posts 99980 down to 99971. Always include &lt;code&gt;ORDER BY&lt;/code&gt; when you paginate, because SQL doesn't promise any row order without it and two pages could share rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Problem one: deep pages get slow
&lt;/h2&gt;

&lt;p&gt;Interviewers usually expect you to name two problems with offset. The first is speed, and &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; shows why: it runs the query and prints the plan, the list of steps the database took, with how many rows each step handled.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="k"&gt;ANALYZE&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="k"&gt;OFFSET&lt;/span&gt; &lt;span class="mi"&gt;90000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are the two lines that matter, with the timing details cut:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Limit  (...) (actual ... rows=10.00 ...)
  -&amp;gt;  Index Scan Backward using posts_pkey on posts  (...) (actual ... rows=90010.00 ...)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the plan from the bottom up: the index scan walked through 90,010 rows, and the &lt;code&gt;Limit&lt;/code&gt; step threw away the first 90,000 and kept 10. Page 9,001 does all that work every time someone opens it, and it gets worse as the table grows. PostgreSQL 18 prints row counts with two decimals, so older versions show &lt;code&gt;rows=10&lt;/code&gt; instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Problem two: new rows shift the pages
&lt;/h2&gt;

&lt;p&gt;The second problem appears when the data changes between two requests. Run these three statements in order, as if a user loads page 1, someone publishes a post, and then the user loads page 2.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;OFFSET&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- 100000, 99999&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Brand new post'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;-- gets id 100001&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;OFFSET&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- 99999, 99998&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Post 99999 shows up on both pages, because the new row pushed every post down one spot. A deleted row does the opposite and makes the user skip one, and since neither case throws an error, these bugs are easy to miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Cursor pagination
&lt;/h2&gt;

&lt;p&gt;A cursor is a small value that marks the last row the user saw, like a bookmark. The next query asks only for rows older than the bookmark.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- the last post the user saw was 99991, so get the 10 after it&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;99991&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This returns posts 99990 down to 99981. New posts get bigger ids and land above the bookmark, so they can't push anything onto the next page.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Why the cursor stays fast
&lt;/h2&gt;

&lt;p&gt;To compare with section 3, ask for the same deep page, posts 10000 down to 9991, with a cursor.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="k"&gt;ANALYZE&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;10001&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Limit  (...) (actual ... rows=10.00 ...)
  -&amp;gt;  Index Scan Backward using posts_pkey on posts  (...) (actual ... rows=10.00 ...)
        Index Cond: (id &amp;lt; 10001)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Index Cond&lt;/code&gt; means Postgres used the index to jump straight to id 10000, so it handled 10 rows instead of 90,010. The cursor query does the same small amount of work on page 2 or page 9,001.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Know when to stop
&lt;/h2&gt;

&lt;p&gt;The app also needs to know whether another page exists. Ask for one row more than you show, so 11 rows for a page of 10.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only 10 rows come back (posts 10 down to 1), so this is the last page. When the 11th row does come back, the app shows the first 10 and sends the 10th row's id as the next cursor, usually in JSON like &lt;code&gt;{ "items": [...], "nextCursor": 9991 }&lt;/code&gt;. The client sends that cursor with its next request, and it becomes the number in &lt;code&gt;WHERE id &amp;lt; ...&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. When the sort column can repeat
&lt;/h2&gt;

&lt;p&gt;Sorting by id works because ids never repeat. Many apps sort by &lt;code&gt;created_at&lt;/code&gt; instead, where two posts can share the same time, and a plain &lt;code&gt;WHERE created_at &amp;lt; ...&lt;/code&gt; would skip any post with exactly the same time as the bookmark. The fix is a tie-breaker, a second sort column that is unique, like &lt;code&gt;id&lt;/code&gt;, plus an index on both columns.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;posts_created_at_id_idx&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-03-11 10:31:00+00'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;99991&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;(created_at, id) &amp;lt; (x, y)&lt;/code&gt; compares pairs the way a dictionary compares words: time first, then id only when the times match. The cursor now holds two values, the last row's time and id, and because the index has the same columns in the same order as the &lt;code&gt;ORDER BY&lt;/code&gt;, Postgres can still jump straight to the bookmark.&lt;/p&gt;

&lt;h2&gt;
  
  
  That's it
&lt;/h2&gt;

&lt;p&gt;Offset pagination counts rows to skip, cursor pagination continues from a bookmark, and that bookmark keeps pages fast and free of repeats when the table changes.&lt;/p&gt;

&lt;p&gt;Two practical tips:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; on your own pagination queries and compare the actual rows with the rows you return, since a big gap means wasted work.&lt;/li&gt;
&lt;li&gt;In an interview, lead with the short answer: offset fits admin tables where people jump to page 7, and cursor fits feeds, infinite scroll, and tables that change while people read them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Which one does your current project use, and have you ever caught the duplicate-post bug in the wild?&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>sql</category>
      <category>interview</category>
      <category>pagination</category>
    </item>
    <item>
      <title>TypeScript Basics You Need for Next.js</title>
      <dc:creator>Israa</dc:creator>
      <pubDate>Mon, 02 Nov 2026 14:33:55 +0000</pubDate>
      <link>https://dev.to/israay/typescript-basics-you-need-for-nextjs-2l62</link>
      <guid>https://dev.to/israay/typescript-basics-you-need-for-nextjs-2l62</guid>
      <description>&lt;p&gt;If you're starting a Next.js project, it probably came with TypeScript turned on. That can feel scary if you've only written plain JavaScript.&lt;/p&gt;

&lt;p&gt;The whole language is a lot. The part you need for Next.js is about seven small ideas, and this post sticks to those, with tiny examples. Ten minutes, tops.&lt;/p&gt;

&lt;p&gt;TypeScript is just JavaScript with labels on your data. The labels tell your editor what a value is, so it can catch mistakes before you even run the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Basic types
&lt;/h2&gt;

&lt;p&gt;You add a type after a colon. That's the whole syntax.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Sara"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;isAdmin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"next"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"react"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most of the time you can skip the type and TypeScript figures it out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Sara"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// TypeScript already knows this is a string&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Describing objects
&lt;/h2&gt;

&lt;p&gt;For objects, you describe the shape once with &lt;code&gt;type&lt;/code&gt;, then reuse it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;sara&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Sara"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now if you forget &lt;code&gt;age&lt;/code&gt; or spell &lt;code&gt;name&lt;/code&gt; wrong, your editor underlines it in red.&lt;/p&gt;

&lt;p&gt;You'll also see &lt;code&gt;interface User { ... }&lt;/code&gt;. For beginners, it does the same job as &lt;code&gt;type&lt;/code&gt;. Pick one and don't worry about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Typing component props
&lt;/h2&gt;

&lt;p&gt;This is the thing you'll do most in Next.js. Props are just an object, so you describe them the same way.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nc"&gt;ButtonProps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;onClick&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ButtonProps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;button&lt;/span&gt; &lt;span class="n"&gt;onClick&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;button&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;() =&amp;gt; void&lt;/code&gt; means "a function that takes nothing and returns nothing."&lt;/p&gt;

&lt;p&gt;Now when you write &lt;code&gt;&amp;lt;Button /&amp;gt;&lt;/code&gt; somewhere and forget &lt;code&gt;label&lt;/code&gt;, Next.js tells you right in the editor instead of showing a broken button in the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Optional and "one of these" types
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;?&lt;/code&gt; after a name means "this is optional."&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;|&lt;/code&gt; means "one of these values."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nc"&gt;ButtonProps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;variant&lt;/span&gt;&lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="s2"&gt;"primary"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="s2"&gt;"secondary"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// optional, and only these two words&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;&amp;lt;Button label="Save" /&amp;gt;&lt;/code&gt; works, and &lt;code&gt;&amp;lt;Button label="Save" variant="big" /&amp;gt;&lt;/code&gt; gets a red underline. You get autocomplete for &lt;code&gt;variant&lt;/code&gt; too.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Typing state
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;useState&lt;/code&gt; usually guesses the type from the starting value, so this just works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;setCount&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// number&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It only needs help when it starts empty. Tell it what will go in using angle brackets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;setUser&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That reads as "this state holds a &lt;code&gt;User&lt;/code&gt;, or nothing yet." Angle brackets are how you pass a type to a function.&lt;/p&gt;

&lt;p&gt;Don't forget &lt;code&gt;"use client"&lt;/code&gt; at the top of any file that uses &lt;code&gt;useState&lt;/code&gt; in Next.js.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Typing data you fetch
&lt;/h2&gt;

&lt;p&gt;When you fetch from an API, TypeScript has no idea what comes back, so you have to tell it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nc"&gt;Post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getPosts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"https://api.example.com/posts"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Promise&amp;lt;Post[]&amp;gt;&lt;/code&gt; means "this async function will eventually give you a list of posts."&lt;/p&gt;

&lt;p&gt;After that, anywhere you use &lt;code&gt;posts&lt;/code&gt;, your editor knows each one has an &lt;code&gt;id&lt;/code&gt; and a &lt;code&gt;title&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. The two Next.js-specific bits
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Page params
&lt;/h3&gt;

&lt;p&gt;In the App Router, a page like &lt;code&gt;app/blog/[slug]/page.tsx&lt;/code&gt; gets its &lt;code&gt;slug&lt;/code&gt; through &lt;code&gt;params&lt;/code&gt;. Since Next.js 15, &lt;code&gt;params&lt;/code&gt; is a Promise, so you type it like this and &lt;code&gt;await&lt;/code&gt; it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nc"&gt;BlogPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;h1&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;h1&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see old tutorials with &lt;code&gt;params: { slug: string }&lt;/code&gt; and no &lt;code&gt;await&lt;/code&gt;, that's the pre-15 way. It will show a type error on current versions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Metadata
&lt;/h3&gt;

&lt;p&gt;Next.js ships its own types. The one you'll meet first is &lt;code&gt;Metadata&lt;/code&gt; for your page title:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;Metadata&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;"next"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"My Blog"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding &lt;code&gt;: Metadata&lt;/code&gt; gets you autocomplete for every allowed field, which is most of the reason to use the built-in types at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  That's it
&lt;/h2&gt;

&lt;p&gt;Those seven ideas cover most of what you'll write as a beginner.&lt;/p&gt;

&lt;p&gt;Two tips for the road:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;When you're stuck, hover over the red underline. The error message usually says exactly what's missing.&lt;/li&gt;
&lt;li&gt;Avoid &lt;code&gt;any&lt;/code&gt;. It turns TypeScript off for that value, which hides the mistakes you wanted it to catch.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything else (generics, utility types, type guards) can wait until you actually need it. Build something first.&lt;/p&gt;

&lt;p&gt;What tripped you up when you first used TypeScript with Next.js? Drop it in the comments.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>nextjs</category>
      <category>react</category>
      <category>beginners</category>
    </item>
    <item>
      <title>I added a Postgres constraint to stop double bookings. It deadlocked instead.</title>
      <dc:creator>Israa</dc:creator>
      <pubDate>Sat, 17 Oct 2026 18:31:23 +0000</pubDate>
      <link>https://dev.to/israay/i-added-a-postgres-constraint-to-stop-double-bookings-it-deadlocked-instead-1meo</link>
      <guid>https://dev.to/israay/i-added-a-postgres-constraint-to-stop-double-bookings-it-deadlocked-instead-1meo</guid>
      <description>&lt;p&gt;I'm building a dental clinic booking API (NestJS, Drizzle, Postgres) as a portfolio project. The rule I care most about is that two patients can never have the same doctor at the same time.&lt;/p&gt;

&lt;p&gt;I knew the textbook answer. If the app checks "is this slot free?" and then inserts, two requests can both check, both see "free", and both insert. So the database has to enforce the rule itself. Postgres has exclusion constraints for exactly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="no"&gt;EXTENSION&lt;/span&gt; &lt;span class="k"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;btree_gist&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- lets the index compare uuids with =&lt;/span&gt;

&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;appointments&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;appointments_no_overlap&lt;/span&gt;
  &lt;span class="no"&gt;EXCLUDE&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gist&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doctor_id&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;tstzrange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;starts_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ends_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'cancelled'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In plain words: no two appointments that aren't cancelled may have the same doctor and overlapping times. An insert that breaks the rule fails with error code &lt;code&gt;23P01&lt;/code&gt;, which my API turns into a 409 "slot taken" response. Every code path gets the check for free, and nobody can forget it. Done, I thought.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke
&lt;/h2&gt;

&lt;p&gt;I wrote a script that fires ten booking transactions for the same slot straight at the database, all at the same moment, and ran it ten times. I expected one winner and nine quick &lt;code&gt;23P01&lt;/code&gt; errors per round, which the API would turn into 409s.&lt;/p&gt;

&lt;p&gt;Each round did get exactly one winner. But 36 of the 90 losers never got a &lt;code&gt;23P01&lt;/code&gt;. They got a deadlock error (&lt;code&gt;40P01&lt;/code&gt;) instead, and the slowest request took 9 seconds. Retrying on deadlock made things worse, because the retries collided with each other again.&lt;/p&gt;

&lt;p&gt;The data was always correct, never more than one booking per slot. But a patient who waits nine seconds for an error page does not care about that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it happens
&lt;/h2&gt;

&lt;p&gt;A unique index checks for duplicates and writes its entry in one step, so two inserts can't both pass the check. An exclusion constraint works the other way round. The Postgres source (&lt;a href="https://github.com/postgres/postgres/blob/REL_17_STABLE/src/backend/executor/execIndexing.c" rel="noopener noreferrer"&gt;&lt;code&gt;execIndexing.c&lt;/code&gt;&lt;/a&gt;) says it in one line: "we always insert first and then check".&lt;/p&gt;

&lt;p&gt;So when two transactions, A and B, insert overlapping rows at nearly the same time:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A writes its row. B writes its row.&lt;/li&gt;
&lt;li&gt;A checks for conflicts, finds B's unfinished row, and waits to see if B commits.&lt;/li&gt;
&lt;li&gt;B checks, finds A's unfinished row, and waits for A.&lt;/li&gt;
&lt;li&gt;Each waits for the other. After &lt;code&gt;deadlock_timeout&lt;/code&gt; (1 second by default), Postgres notices and kills one of them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With ten transactions at once, these waits chain into multi-second stalls. The same source file calls this deadlock "fairly harmless", since one of the two was going to fail anyway, and adds that you just get a different error message. That different error message, after a nine-second wait, is exactly the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: one line before the insert
&lt;/h2&gt;

&lt;p&gt;The booking transaction now starts by locking the doctor's row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;doctors&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$1&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only one transaction can hold that lock at a time, so bookings for the same doctor now wait in line. The next one gets the lock only after the previous one has finished. By then the earlier booking is committed, so the new insert meets a committed overlapping row and fails at once with &lt;code&gt;23P01&lt;/code&gt;. Two unfinished overlapping inserts never exist at the same time, so the deadlock can't form. Bookings for different doctors lock different rows and never wait on each other. The transaction still does its other checks, like whether the doctor offers that slot at all; the constraint only guards overlaps.&lt;/p&gt;

&lt;p&gt;Same script, same ten rounds, Postgres 17 in Docker on my laptop. Your numbers will differ; the shape should not:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variant&lt;/th&gt;
&lt;th&gt;Slowest request&lt;/th&gt;
&lt;th&gt;What the 90 losers got&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Constraint only&lt;/td&gt;
&lt;td&gt;9,079 ms&lt;/td&gt;
&lt;td&gt;54 × &lt;code&gt;23P01&lt;/code&gt;, 36 × deadlock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Constraint + retry 3× on deadlock&lt;/td&gt;
&lt;td&gt;11,051 ms&lt;/td&gt;
&lt;td&gt;27 × &lt;code&gt;23P01&lt;/code&gt;, 63 × deadlock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Constraint + lock doctor row first&lt;/td&gt;
&lt;td&gt;20 ms&lt;/td&gt;
&lt;td&gt;90 × &lt;code&gt;23P01&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;p&gt;Keep both the constraint and the lock, because they do different jobs. The constraint is the guarantee: if some future code path forgets the lock, the worst case is a slow, ugly error, never a double booking. The lock makes losing fast and clean. The reverse, a lock with no constraint, is dangerous, because code that forgets the lock double-books silently.&lt;/p&gt;

&lt;p&gt;And a concurrency test that passes once proves nothing. An earlier script of mine tested plain check-then-insert and looked safe, only because the ten transactions happened to run almost one after another. Adding a 20 ms sleep between the check and the insert, to stand in for real work, let all ten book the same slot.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>backend</category>
      <category>node</category>
    </item>
  </channel>
</rss>
