[{"data":1,"prerenderedAt":2929},["ShallowReactive",2],{"page-\u002Fadvanced-query-patterns-and-bulk-data-operations\u002Fstreaming-large-result-sets-with-yield-per\u002Fpaginating-large-result-sets-with-keyset-pagination\u002F":3},{"id":4,"title":5,"body":6,"description":2921,"extension":2922,"meta":2923,"navigation":77,"path":2925,"seo":2926,"stem":2927,"__hash__":2928},"content\u002Fadvanced-query-patterns-and-bulk-data-operations\u002Fstreaming-large-result-sets-with-yield-per\u002Fpaginating-large-result-sets-with-keyset-pagination\u002Findex.md","Paginating Large Result Sets with Keyset Pagination in SQLAlchemy",{"type":7,"value":8,"toc":2905},"minimark",[9,13,32,37,43,463,468,879,882,886,891,898,908,1074,1078,1097,1108,1112,1131,1611,1623,1625,1629,1817,1819,1823,1827,1837,1843,2233,2237,2248,2292,2303,2307,2322,2761,2772,2774,2778,2783,2813,2818,2827,2832,2843,2851,2869,2871,2875,2901],[10,11,5],"h1",{"id":12},"paginating-large-result-sets-with-keyset-pagination-in-sqlalchemy",[14,15,16,17,21,22,25,26,31],"p",{},"Keyset pagination (also called seek pagination) replaces ",[18,19,20],"code",{},"OFFSET"," with a ",[18,23,24],{},"WHERE (created_at, id) > (:last_ts, :last_id)"," clause so every page fetch is an O(log N) index seek instead of an O(N) sequential scan — this page belongs to the ",[27,28,30],"a",{"href":29},"\u002Fadvanced-query-patterns-and-bulk-data-operations\u002Fstreaming-large-result-sets-with-yield-per\u002F","Streaming Large Result Sets with yield_per"," cluster.",[33,34,36],"h2",{"id":35},"quick-answer","Quick Answer",[14,38,39],{},[40,41,42],"strong",{},"Before — OFFSET pagination that degrades at scale:",[44,45,50],"pre",{"className":46,"code":47,"language":48,"meta":49,"style":49},"language-python shiki shiki-themes github-light github-dark","from __future__ import annotations\n\nimport asyncio\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession, create_async_engine\nfrom sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column\nimport datetime\n\n\nclass Base(DeclarativeBase):\n    pass\n\n\nclass Order(Base):\n    __tablename__ = \"orders\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    created_at: Mapped[datetime.datetime] = mapped_column(index=True)\n    customer_id: Mapped[int] = mapped_column()\n    total_cents: Mapped[int] = mapped_column()\n\n\nengine = create_async_engine(\"postgresql+asyncpg:\u002F\u002Fuser:pass@host\u002Fdb\")\n\nPAGE_SIZE = 100\n\n\nasync def fetch_orders_offset(page: int) -> list[Order]:\n    \"\"\"OFFSET grows O(N) — Postgres discards `page * PAGE_SIZE` rows on every call.\"\"\"\n    async with AsyncSession(engine) as session:\n        result = await session.execute(\n            select(Order)\n            .order_by(Order.created_at, Order.id)\n            .limit(PAGE_SIZE)\n            .offset(page * PAGE_SIZE)  # Full scan cost grows with each page\n        )\n        return list(result.scalars())\n","python","",[18,51,52,72,79,88,101,114,127,135,140,145,164,170,175,180,195,208,213,245,264,279,293,298,303,319,324,336,341,346,366,372,390,404,410,416,426,445,451],{"__ignoreMap":49},[53,54,57,61,65,68],"span",{"class":55,"line":56},"line",1,[53,58,60],{"class":59},"szBVR","from",[53,62,64],{"class":63},"sj4cs"," __future__",[53,66,67],{"class":59}," import",[53,69,71],{"class":70},"sVt8B"," annotations\n",[53,73,75],{"class":55,"line":74},2,[53,76,78],{"emptyLinePlaceholder":77},true,"\n",[53,80,82,85],{"class":55,"line":81},3,[53,83,84],{"class":59},"import",[53,86,87],{"class":70}," asyncio\n",[53,89,91,93,96,98],{"class":55,"line":90},4,[53,92,60],{"class":59},[53,94,95],{"class":70}," sqlalchemy ",[53,97,84],{"class":59},[53,99,100],{"class":70}," select\n",[53,102,104,106,109,111],{"class":55,"line":103},5,[53,105,60],{"class":59},[53,107,108],{"class":70}," sqlalchemy.ext.asyncio ",[53,110,84],{"class":59},[53,112,113],{"class":70}," AsyncSession, create_async_engine\n",[53,115,117,119,122,124],{"class":55,"line":116},6,[53,118,60],{"class":59},[53,120,121],{"class":70}," sqlalchemy.orm ",[53,123,84],{"class":59},[53,125,126],{"class":70}," DeclarativeBase, Mapped, mapped_column\n",[53,128,130,132],{"class":55,"line":129},7,[53,131,84],{"class":59},[53,133,134],{"class":70}," datetime\n",[53,136,138],{"class":55,"line":137},8,[53,139,78],{"emptyLinePlaceholder":77},[53,141,143],{"class":55,"line":142},9,[53,144,78],{"emptyLinePlaceholder":77},[53,146,148,151,155,158,161],{"class":55,"line":147},10,[53,149,150],{"class":59},"class",[53,152,154],{"class":153},"sScJk"," Base",[53,156,157],{"class":70},"(",[53,159,160],{"class":153},"DeclarativeBase",[53,162,163],{"class":70},"):\n",[53,165,167],{"class":55,"line":166},11,[53,168,169],{"class":59},"    pass\n",[53,171,173],{"class":55,"line":172},12,[53,174,78],{"emptyLinePlaceholder":77},[53,176,178],{"class":55,"line":177},13,[53,179,78],{"emptyLinePlaceholder":77},[53,181,183,185,188,190,193],{"class":55,"line":182},14,[53,184,150],{"class":59},[53,186,187],{"class":153}," Order",[53,189,157],{"class":70},[53,191,192],{"class":153},"Base",[53,194,163],{"class":70},[53,196,198,201,204],{"class":55,"line":197},15,[53,199,200],{"class":70},"    __tablename__ ",[53,202,203],{"class":59},"=",[53,205,207],{"class":206},"sZZnC"," \"orders\"\n",[53,209,211],{"class":55,"line":210},16,[53,212,78],{"emptyLinePlaceholder":77},[53,214,216,219,222,225,228,230,233,237,239,242],{"class":55,"line":215},17,[53,217,218],{"class":63},"    id",[53,220,221],{"class":70},": Mapped[",[53,223,224],{"class":63},"int",[53,226,227],{"class":70},"] ",[53,229,203],{"class":59},[53,231,232],{"class":70}," mapped_column(",[53,234,236],{"class":235},"s4XuR","primary_key",[53,238,203],{"class":59},[53,240,241],{"class":63},"True",[53,243,244],{"class":70},")\n",[53,246,248,251,253,255,258,260,262],{"class":55,"line":247},18,[53,249,250],{"class":70},"    created_at: Mapped[datetime.datetime] ",[53,252,203],{"class":59},[53,254,232],{"class":70},[53,256,257],{"class":235},"index",[53,259,203],{"class":59},[53,261,241],{"class":63},[53,263,244],{"class":70},[53,265,267,270,272,274,276],{"class":55,"line":266},19,[53,268,269],{"class":70},"    customer_id: Mapped[",[53,271,224],{"class":63},[53,273,227],{"class":70},[53,275,203],{"class":59},[53,277,278],{"class":70}," mapped_column()\n",[53,280,282,285,287,289,291],{"class":55,"line":281},20,[53,283,284],{"class":70},"    total_cents: Mapped[",[53,286,224],{"class":63},[53,288,227],{"class":70},[53,290,203],{"class":59},[53,292,278],{"class":70},[53,294,296],{"class":55,"line":295},21,[53,297,78],{"emptyLinePlaceholder":77},[53,299,301],{"class":55,"line":300},22,[53,302,78],{"emptyLinePlaceholder":77},[53,304,306,309,311,314,317],{"class":55,"line":305},23,[53,307,308],{"class":70},"engine ",[53,310,203],{"class":59},[53,312,313],{"class":70}," create_async_engine(",[53,315,316],{"class":206},"\"postgresql+asyncpg:\u002F\u002Fuser:pass@host\u002Fdb\"",[53,318,244],{"class":70},[53,320,322],{"class":55,"line":321},24,[53,323,78],{"emptyLinePlaceholder":77},[53,325,327,330,333],{"class":55,"line":326},25,[53,328,329],{"class":63},"PAGE_SIZE",[53,331,332],{"class":59}," =",[53,334,335],{"class":63}," 100\n",[53,337,339],{"class":55,"line":338},26,[53,340,78],{"emptyLinePlaceholder":77},[53,342,344],{"class":55,"line":343},27,[53,345,78],{"emptyLinePlaceholder":77},[53,347,349,352,355,358,361,363],{"class":55,"line":348},28,[53,350,351],{"class":59},"async",[53,353,354],{"class":59}," def",[53,356,357],{"class":153}," fetch_orders_offset",[53,359,360],{"class":70},"(page: ",[53,362,224],{"class":63},[53,364,365],{"class":70},") -> list[Order]:\n",[53,367,369],{"class":55,"line":368},29,[53,370,371],{"class":206},"    \"\"\"OFFSET grows O(N) — Postgres discards `page * PAGE_SIZE` rows on every call.\"\"\"\n",[53,373,375,378,381,384,387],{"class":55,"line":374},30,[53,376,377],{"class":59},"    async",[53,379,380],{"class":59}," with",[53,382,383],{"class":70}," AsyncSession(engine) ",[53,385,386],{"class":59},"as",[53,388,389],{"class":70}," session:\n",[53,391,393,396,398,401],{"class":55,"line":392},31,[53,394,395],{"class":70},"        result ",[53,397,203],{"class":59},[53,399,400],{"class":59}," await",[53,402,403],{"class":70}," session.execute(\n",[53,405,407],{"class":55,"line":406},32,[53,408,409],{"class":70},"            select(Order)\n",[53,411,413],{"class":55,"line":412},33,[53,414,415],{"class":70},"            .order_by(Order.created_at, Order.id)\n",[53,417,419,422,424],{"class":55,"line":418},34,[53,420,421],{"class":70},"            .limit(",[53,423,329],{"class":63},[53,425,244],{"class":70},[53,427,429,432,435,438,441],{"class":55,"line":428},35,[53,430,431],{"class":70},"            .offset(page ",[53,433,434],{"class":59},"*",[53,436,437],{"class":63}," PAGE_SIZE",[53,439,440],{"class":70},")  ",[53,442,444],{"class":443},"sJ8bj","# Full scan cost grows with each page\n",[53,446,448],{"class":55,"line":447},36,[53,449,450],{"class":70},"        )\n",[53,452,454,457,460],{"class":55,"line":453},37,[53,455,456],{"class":59},"        return",[53,458,459],{"class":63}," list",[53,461,462],{"class":70},"(result.scalars())\n",[14,464,465],{},[40,466,467],{},"After — keyset pagination with constant cost per page:",[44,469,471],{"className":46,"code":470,"language":48,"meta":49,"style":49},"from __future__ import annotations\n\nimport asyncio\nimport datetime\nfrom typing import Optional\n\nfrom sqlalchemy import select, tuple_\nfrom sqlalchemy.ext.asyncio import AsyncSession, create_async_engine\nfrom sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column\n\n\nclass Base(DeclarativeBase):\n    pass\n\n\nclass Order(Base):\n    __tablename__ = \"orders\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    created_at: Mapped[datetime.datetime] = mapped_column()\n    customer_id: Mapped[int] = mapped_column()\n    total_cents: Mapped[int] = mapped_column()\n\n\nengine = create_async_engine(\"postgresql+asyncpg:\u002F\u002Fuser:pass@host\u002Fdb\")\n\nPAGE_SIZE = 100\n\n\nasync def fetch_orders_keyset(\n    last_created_at: Optional[datetime.datetime] = None,\n    last_id: Optional[int] = None,\n) -> list[Order]:\n    \"\"\"Keyset seek: always O(log N) index seek regardless of page depth.\"\"\"\n    async with AsyncSession(engine) as session:\n        stmt = select(Order).order_by(Order.created_at, Order.id).limit(PAGE_SIZE)\n\n        if last_created_at is not None and last_id is not None:\n            # tuple_() emits: WHERE (created_at, id) > (:p1, :p2)\n            stmt = stmt.where(\n                tuple_(Order.created_at, Order.id) > (last_created_at, last_id)\n            )\n\n        result = await session.execute(stmt)\n        rows = list(result.scalars())\n        return rows\n",[18,472,473,483,487,493,499,511,515,526,536,546,550,554,566,570,574,578,590,598,602,624,632,644,656,660,664,676,680,688,692,696,708,721,736,740,745,757,771,775,807,813,824,836,842,847,859,871],{"__ignoreMap":49},[53,474,475,477,479,481],{"class":55,"line":56},[53,476,60],{"class":59},[53,478,64],{"class":63},[53,480,67],{"class":59},[53,482,71],{"class":70},[53,484,485],{"class":55,"line":74},[53,486,78],{"emptyLinePlaceholder":77},[53,488,489,491],{"class":55,"line":81},[53,490,84],{"class":59},[53,492,87],{"class":70},[53,494,495,497],{"class":55,"line":90},[53,496,84],{"class":59},[53,498,134],{"class":70},[53,500,501,503,506,508],{"class":55,"line":103},[53,502,60],{"class":59},[53,504,505],{"class":70}," typing ",[53,507,84],{"class":59},[53,509,510],{"class":70}," Optional\n",[53,512,513],{"class":55,"line":116},[53,514,78],{"emptyLinePlaceholder":77},[53,516,517,519,521,523],{"class":55,"line":129},[53,518,60],{"class":59},[53,520,95],{"class":70},[53,522,84],{"class":59},[53,524,525],{"class":70}," select, tuple_\n",[53,527,528,530,532,534],{"class":55,"line":137},[53,529,60],{"class":59},[53,531,108],{"class":70},[53,533,84],{"class":59},[53,535,113],{"class":70},[53,537,538,540,542,544],{"class":55,"line":142},[53,539,60],{"class":59},[53,541,121],{"class":70},[53,543,84],{"class":59},[53,545,126],{"class":70},[53,547,548],{"class":55,"line":147},[53,549,78],{"emptyLinePlaceholder":77},[53,551,552],{"class":55,"line":166},[53,553,78],{"emptyLinePlaceholder":77},[53,555,556,558,560,562,564],{"class":55,"line":172},[53,557,150],{"class":59},[53,559,154],{"class":153},[53,561,157],{"class":70},[53,563,160],{"class":153},[53,565,163],{"class":70},[53,567,568],{"class":55,"line":177},[53,569,169],{"class":59},[53,571,572],{"class":55,"line":182},[53,573,78],{"emptyLinePlaceholder":77},[53,575,576],{"class":55,"line":197},[53,577,78],{"emptyLinePlaceholder":77},[53,579,580,582,584,586,588],{"class":55,"line":210},[53,581,150],{"class":59},[53,583,187],{"class":153},[53,585,157],{"class":70},[53,587,192],{"class":153},[53,589,163],{"class":70},[53,591,592,594,596],{"class":55,"line":215},[53,593,200],{"class":70},[53,595,203],{"class":59},[53,597,207],{"class":206},[53,599,600],{"class":55,"line":247},[53,601,78],{"emptyLinePlaceholder":77},[53,603,604,606,608,610,612,614,616,618,620,622],{"class":55,"line":266},[53,605,218],{"class":63},[53,607,221],{"class":70},[53,609,224],{"class":63},[53,611,227],{"class":70},[53,613,203],{"class":59},[53,615,232],{"class":70},[53,617,236],{"class":235},[53,619,203],{"class":59},[53,621,241],{"class":63},[53,623,244],{"class":70},[53,625,626,628,630],{"class":55,"line":281},[53,627,250],{"class":70},[53,629,203],{"class":59},[53,631,278],{"class":70},[53,633,634,636,638,640,642],{"class":55,"line":295},[53,635,269],{"class":70},[53,637,224],{"class":63},[53,639,227],{"class":70},[53,641,203],{"class":59},[53,643,278],{"class":70},[53,645,646,648,650,652,654],{"class":55,"line":300},[53,647,284],{"class":70},[53,649,224],{"class":63},[53,651,227],{"class":70},[53,653,203],{"class":59},[53,655,278],{"class":70},[53,657,658],{"class":55,"line":305},[53,659,78],{"emptyLinePlaceholder":77},[53,661,662],{"class":55,"line":321},[53,663,78],{"emptyLinePlaceholder":77},[53,665,666,668,670,672,674],{"class":55,"line":326},[53,667,308],{"class":70},[53,669,203],{"class":59},[53,671,313],{"class":70},[53,673,316],{"class":206},[53,675,244],{"class":70},[53,677,678],{"class":55,"line":338},[53,679,78],{"emptyLinePlaceholder":77},[53,681,682,684,686],{"class":55,"line":343},[53,683,329],{"class":63},[53,685,332],{"class":59},[53,687,335],{"class":63},[53,689,690],{"class":55,"line":348},[53,691,78],{"emptyLinePlaceholder":77},[53,693,694],{"class":55,"line":368},[53,695,78],{"emptyLinePlaceholder":77},[53,697,698,700,702,705],{"class":55,"line":374},[53,699,351],{"class":59},[53,701,354],{"class":59},[53,703,704],{"class":153}," fetch_orders_keyset",[53,706,707],{"class":70},"(\n",[53,709,710,713,715,718],{"class":55,"line":392},[53,711,712],{"class":70},"    last_created_at: Optional[datetime.datetime] ",[53,714,203],{"class":59},[53,716,717],{"class":63}," None",[53,719,720],{"class":70},",\n",[53,722,723,726,728,730,732,734],{"class":55,"line":406},[53,724,725],{"class":70},"    last_id: Optional[",[53,727,224],{"class":63},[53,729,227],{"class":70},[53,731,203],{"class":59},[53,733,717],{"class":63},[53,735,720],{"class":70},[53,737,738],{"class":55,"line":412},[53,739,365],{"class":70},[53,741,742],{"class":55,"line":418},[53,743,744],{"class":206},"    \"\"\"Keyset seek: always O(log N) index seek regardless of page depth.\"\"\"\n",[53,746,747,749,751,753,755],{"class":55,"line":428},[53,748,377],{"class":59},[53,750,380],{"class":59},[53,752,383],{"class":70},[53,754,386],{"class":59},[53,756,389],{"class":70},[53,758,759,762,764,767,769],{"class":55,"line":447},[53,760,761],{"class":70},"        stmt ",[53,763,203],{"class":59},[53,765,766],{"class":70}," select(Order).order_by(Order.created_at, Order.id).limit(",[53,768,329],{"class":63},[53,770,244],{"class":70},[53,772,773],{"class":55,"line":453},[53,774,78],{"emptyLinePlaceholder":77},[53,776,778,781,784,787,790,792,795,798,800,802,804],{"class":55,"line":777},38,[53,779,780],{"class":59},"        if",[53,782,783],{"class":70}," last_created_at ",[53,785,786],{"class":59},"is",[53,788,789],{"class":59}," not",[53,791,717],{"class":63},[53,793,794],{"class":59}," and",[53,796,797],{"class":70}," last_id ",[53,799,786],{"class":59},[53,801,789],{"class":59},[53,803,717],{"class":63},[53,805,806],{"class":70},":\n",[53,808,810],{"class":55,"line":809},39,[53,811,812],{"class":443},"            # tuple_() emits: WHERE (created_at, id) > (:p1, :p2)\n",[53,814,816,819,821],{"class":55,"line":815},40,[53,817,818],{"class":70},"            stmt ",[53,820,203],{"class":59},[53,822,823],{"class":70}," stmt.where(\n",[53,825,827,830,833],{"class":55,"line":826},41,[53,828,829],{"class":70},"                tuple_(Order.created_at, Order.id) ",[53,831,832],{"class":59},">",[53,834,835],{"class":70}," (last_created_at, last_id)\n",[53,837,839],{"class":55,"line":838},42,[53,840,841],{"class":70},"            )\n",[53,843,845],{"class":55,"line":844},43,[53,846,78],{"emptyLinePlaceholder":77},[53,848,850,852,854,856],{"class":55,"line":849},44,[53,851,395],{"class":70},[53,853,203],{"class":59},[53,855,400],{"class":59},[53,857,858],{"class":70}," session.execute(stmt)\n",[53,860,862,865,867,869],{"class":55,"line":861},45,[53,863,864],{"class":70},"        rows ",[53,866,203],{"class":59},[53,868,459],{"class":63},[53,870,462],{"class":70},[53,872,874,876],{"class":55,"line":873},46,[53,875,456],{"class":59},[53,877,878],{"class":70}," rows\n",[880,881],"hr",{},[33,883,885],{"id":884},"execution-context-async-workflow-integration","Execution Context & Async Workflow Integration",[887,888,890],"h3",{"id":889},"why-offset-degrades-at-scale","Why OFFSET degrades at scale",[14,892,893,894,897],{},"PostgreSQL evaluates ",[18,895,896],{},"OFFSET N"," by executing the full query plan, scanning the index, fetching each heap row, and then discarding the first N results before returning the page. For a table of one million orders, fetching page 500 at 100 rows per page means Postgres reads and discards 50,000 rows every single time. The cost is strictly proportional to the offset value — doubling the page number doubles the query time.",[14,899,900,901,903,904,907],{},"Keyset pagination eliminates this by converting \"skip N rows\" into a direct index seek. The compound condition ",[18,902,24],{}," lets Postgres start reading from exactly where the previous page ended. With a composite index on ",[18,905,906],{},"(created_at, id)",", the database descends the B-tree to the cursor position in O(log N) time and reads forward. Page 5,000 costs the same as page 1.",[909,910,913],"figure",{"className":911},[912],"diagram",[914,915,920,921,920,925,920,920,929,920,937,920,920,946,920,920,956,920,961,920,964,920,967,973,976,979,982,920,920,920,986,920,920,994,920,920,999,920,920,1004,920,920,1009,920,920,1012,920,1017,920,1020,920,1023,920,1026,920,1029,1034,1038,1042,1046,1050,920,920,1055,920,1061,920,1067,920,1070],"svg",{"viewBox":916,"role":917,"ariaLabel":918,"xmlns":919},"0 0 640 260","img","Comparison of OFFSET pagination cost vs keyset pagination cost across page numbers","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[922,923,924],"title",{},"OFFSET vs Keyset Pagination Cost",[926,927,928],"desc",{},"Bar chart showing that OFFSET pagination query cost grows linearly with page number while keyset pagination maintains constant cost at every page.",[930,931],"rect",{"x":932,"y":932,"width":933,"height":934,"fill":935,"rx":936},"0","640","260","#f1f9f6","8",[938,939,945],"text",{"x":940,"y":941,"fill":942,"transform":943,"style":944},"18","130","#3f4f4b","rotate(-90,18,130)","text-anchor:middle;font-size:11px","\nRows scanned\n",[930,947],{"x":948,"y":949,"width":950,"height":951,"fill":952,"rx":953,"stroke":954,"style":955},"60","20","560","190","white","4","#cde8e2","stroke-width:1",[55,957],{"x1":948,"y1":958,"x2":959,"y2":958,"stroke":954,"style":960},"67","620","stroke-width:0.5;stroke-dasharray:4,3",[55,962],{"x1":948,"y1":963,"x2":959,"y2":963,"stroke":954,"style":960},"114",[55,965],{"x1":948,"y1":966,"x2":959,"y2":966,"stroke":954,"style":960},"161",[938,968,972],{"x":969,"y":970,"fill":942,"style":971},"55","210","text-anchor:end;font-size:10px","\n0\n",[938,974,975],{"x":969,"y":966,"fill":942,"style":971},"\n25k\n",[938,977,978],{"x":969,"y":963,"fill":942,"style":971},"\n50k\n",[938,980,981],{"x":969,"y":958,"fill":942,"style":971},"\n75k\n",[938,983,985],{"x":969,"y":984,"fill":942,"style":971},"26","\n100k\n",[930,987],{"x":988,"y":989,"width":990,"height":991,"fill":992,"opacity":993},"75","191","30","19","#1f9f95","0.85",[930,995],{"x":996,"y":997,"width":990,"height":998,"fill":992,"opacity":993},"170","172","38",[930,1000],{"x":1001,"y":1002,"width":990,"height":1003,"fill":992,"opacity":993},"265","134","76",[930,1005],{"x":1006,"y":1007,"width":990,"height":1008,"fill":992,"opacity":993},"360","77","133",[930,1010],{"x":1011,"y":949,"width":990,"height":951,"fill":992,"opacity":993},"455",[930,1013],{"x":1014,"y":1015,"width":990,"height":936,"fill":1016},"110","202","#113f39",[930,1018],{"x":1019,"y":1015,"width":990,"height":936,"fill":1016},"205",[930,1021],{"x":1022,"y":1015,"width":990,"height":936,"fill":1016},"300",[930,1024],{"x":1025,"y":1015,"width":990,"height":936,"fill":1016},"395",[930,1027],{"x":1028,"y":1015,"width":990,"height":936,"fill":1016},"490",[938,1030,1033],{"x":1031,"y":1032,"fill":942,"style":944},"90","225","\np.1\n",[938,1035,1037],{"x":1036,"y":1032,"fill":942,"style":944},"185","\np.2\n",[938,1039,1041],{"x":1040,"y":1032,"fill":942,"style":944},"280","\np.3\n",[938,1043,1045],{"x":1044,"y":1032,"fill":942,"style":944},"375","\np.4\n",[938,1047,1049],{"x":1048,"y":1032,"fill":942,"style":944},"470","\np.5\n",[938,1051,1054],{"x":1052,"y":1053,"fill":942,"style":944},"340","245","\nPage Number\n",[930,1056],{"x":1057,"y":1058,"width":1059,"height":1060,"fill":992,"opacity":993},"62","238","12","10",[938,1062,1066],{"x":1063,"y":1064,"fill":942,"style":1065},"78","248","text-anchor:start;font-size:10px","OFFSET (linear scan)",[930,1068],{"x":1069,"y":1058,"width":1059,"height":1060,"fill":1016},"195",[938,1071,1073],{"x":1072,"y":1064,"fill":942,"style":1065},"211","Keyset (index seek)",[887,1075,1077],{"id":1076},"how-sqlalchemy-constructs-the-keyset-where-clause","How SQLAlchemy constructs the keyset WHERE clause",[14,1079,1080,1081,1084,1085,1088,1089,1092,1093,1096],{},"SQLAlchemy 2.0 exposes ",[18,1082,1083],{},"tuple_()"," from ",[18,1086,1087],{},"sqlalchemy"," to emit row-value comparisons. ",[18,1090,1091],{},"tuple_(Order.created_at, Order.id) > (last_ts, last_id)"," compiles directly to the PostgreSQL row-value expression ",[18,1094,1095],{},"(created_at, id) > ($1, $2)",", which the query planner recognises as an index range condition and satisfies with a single B-tree descent.",[14,1098,1099,1100,1103,1104,1107],{},"The unique tie-breaking column — ",[18,1101,1102],{},"id"," in the examples above — is mandatory. Without it, two orders with identical ",[18,1105,1106],{},"created_at"," timestamps that straddle a page boundary can be silently omitted or duplicated as the cursor advances.",[887,1109,1111],{"id":1110},"async-implementation-with-asyncsession","Async implementation with AsyncSession",[14,1113,1114,1115,1118,1119,1122,1123,1126,1127,1130],{},"Because ",[18,1116,1117],{},"AsyncSession.execute()"," is a coroutine, keyset pagination integrates cleanly into ",[18,1120,1121],{},"asyncio","-based web handlers and background tasks. The cursor state (",[18,1124,1125],{},"last_created_at",", ",[18,1128,1129],{},"last_id",") is carried between requests either in a signed token returned to the API caller, or held server-side in a Redis key for background batch jobs.",[44,1132,1134],{"className":46,"code":1133,"language":48,"meta":49,"style":49},"from __future__ import annotations\n\nimport asyncio\nimport datetime\nfrom dataclasses import dataclass\nfrom typing import Optional\n\nfrom sqlalchemy import select, tuple_\nfrom sqlalchemy.ext.asyncio import AsyncSession, create_async_engine\nfrom sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column\n\n\nclass Base(DeclarativeBase):\n    pass\n\n\nclass Order(Base):\n    __tablename__ = \"orders\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    created_at: Mapped[datetime.datetime] = mapped_column()\n    customer_id: Mapped[int] = mapped_column()\n    total_cents: Mapped[int] = mapped_column()\n\n\n@dataclass\nclass PageCursor:\n    last_created_at: datetime.datetime\n    last_id: int\n\n\nengine = create_async_engine(\"postgresql+asyncpg:\u002F\u002Fuser:pass@host\u002Fdb\")\nPAGE_SIZE = 100\n\n\nasync def paginate_orders(\n    cursor: Optional[PageCursor] = None,\n) -> tuple[list[Order], Optional[PageCursor]]:\n    \"\"\"Return one page of Orders and a cursor for the next page.\"\"\"\n    async with AsyncSession(engine) as session:\n        stmt = select(Order).order_by(Order.created_at, Order.id).limit(PAGE_SIZE)\n        if cursor is not None:\n            stmt = stmt.where(\n                tuple_(Order.created_at, Order.id)\n                > (cursor.last_created_at, cursor.last_id)\n            )\n        result = await session.execute(stmt)\n        rows = list(result.scalars())\n\n    next_cursor: Optional[PageCursor] = None\n    if len(rows) == PAGE_SIZE:\n        last = rows[-1]\n        next_cursor = PageCursor(last_created_at=last.created_at, last_id=last.id)\n\n    return rows, next_cursor\n",[18,1135,1136,1146,1150,1156,1162,1174,1184,1188,1198,1208,1218,1222,1226,1238,1242,1246,1250,1262,1270,1274,1296,1304,1316,1328,1332,1336,1341,1350,1355,1363,1367,1371,1383,1391,1395,1399,1410,1421,1426,1431,1443,1455,1470,1478,1483,1491,1495,1506,1517,1522,1533,1552,1572,1597,1602],{"__ignoreMap":49},[53,1137,1138,1140,1142,1144],{"class":55,"line":56},[53,1139,60],{"class":59},[53,1141,64],{"class":63},[53,1143,67],{"class":59},[53,1145,71],{"class":70},[53,1147,1148],{"class":55,"line":74},[53,1149,78],{"emptyLinePlaceholder":77},[53,1151,1152,1154],{"class":55,"line":81},[53,1153,84],{"class":59},[53,1155,87],{"class":70},[53,1157,1158,1160],{"class":55,"line":90},[53,1159,84],{"class":59},[53,1161,134],{"class":70},[53,1163,1164,1166,1169,1171],{"class":55,"line":103},[53,1165,60],{"class":59},[53,1167,1168],{"class":70}," dataclasses ",[53,1170,84],{"class":59},[53,1172,1173],{"class":70}," dataclass\n",[53,1175,1176,1178,1180,1182],{"class":55,"line":116},[53,1177,60],{"class":59},[53,1179,505],{"class":70},[53,1181,84],{"class":59},[53,1183,510],{"class":70},[53,1185,1186],{"class":55,"line":129},[53,1187,78],{"emptyLinePlaceholder":77},[53,1189,1190,1192,1194,1196],{"class":55,"line":137},[53,1191,60],{"class":59},[53,1193,95],{"class":70},[53,1195,84],{"class":59},[53,1197,525],{"class":70},[53,1199,1200,1202,1204,1206],{"class":55,"line":142},[53,1201,60],{"class":59},[53,1203,108],{"class":70},[53,1205,84],{"class":59},[53,1207,113],{"class":70},[53,1209,1210,1212,1214,1216],{"class":55,"line":147},[53,1211,60],{"class":59},[53,1213,121],{"class":70},[53,1215,84],{"class":59},[53,1217,126],{"class":70},[53,1219,1220],{"class":55,"line":166},[53,1221,78],{"emptyLinePlaceholder":77},[53,1223,1224],{"class":55,"line":172},[53,1225,78],{"emptyLinePlaceholder":77},[53,1227,1228,1230,1232,1234,1236],{"class":55,"line":177},[53,1229,150],{"class":59},[53,1231,154],{"class":153},[53,1233,157],{"class":70},[53,1235,160],{"class":153},[53,1237,163],{"class":70},[53,1239,1240],{"class":55,"line":182},[53,1241,169],{"class":59},[53,1243,1244],{"class":55,"line":197},[53,1245,78],{"emptyLinePlaceholder":77},[53,1247,1248],{"class":55,"line":210},[53,1249,78],{"emptyLinePlaceholder":77},[53,1251,1252,1254,1256,1258,1260],{"class":55,"line":215},[53,1253,150],{"class":59},[53,1255,187],{"class":153},[53,1257,157],{"class":70},[53,1259,192],{"class":153},[53,1261,163],{"class":70},[53,1263,1264,1266,1268],{"class":55,"line":247},[53,1265,200],{"class":70},[53,1267,203],{"class":59},[53,1269,207],{"class":206},[53,1271,1272],{"class":55,"line":266},[53,1273,78],{"emptyLinePlaceholder":77},[53,1275,1276,1278,1280,1282,1284,1286,1288,1290,1292,1294],{"class":55,"line":281},[53,1277,218],{"class":63},[53,1279,221],{"class":70},[53,1281,224],{"class":63},[53,1283,227],{"class":70},[53,1285,203],{"class":59},[53,1287,232],{"class":70},[53,1289,236],{"class":235},[53,1291,203],{"class":59},[53,1293,241],{"class":63},[53,1295,244],{"class":70},[53,1297,1298,1300,1302],{"class":55,"line":295},[53,1299,250],{"class":70},[53,1301,203],{"class":59},[53,1303,278],{"class":70},[53,1305,1306,1308,1310,1312,1314],{"class":55,"line":300},[53,1307,269],{"class":70},[53,1309,224],{"class":63},[53,1311,227],{"class":70},[53,1313,203],{"class":59},[53,1315,278],{"class":70},[53,1317,1318,1320,1322,1324,1326],{"class":55,"line":305},[53,1319,284],{"class":70},[53,1321,224],{"class":63},[53,1323,227],{"class":70},[53,1325,203],{"class":59},[53,1327,278],{"class":70},[53,1329,1330],{"class":55,"line":321},[53,1331,78],{"emptyLinePlaceholder":77},[53,1333,1334],{"class":55,"line":326},[53,1335,78],{"emptyLinePlaceholder":77},[53,1337,1338],{"class":55,"line":338},[53,1339,1340],{"class":153},"@dataclass\n",[53,1342,1343,1345,1348],{"class":55,"line":343},[53,1344,150],{"class":59},[53,1346,1347],{"class":153}," PageCursor",[53,1349,806],{"class":70},[53,1351,1352],{"class":55,"line":348},[53,1353,1354],{"class":70},"    last_created_at: datetime.datetime\n",[53,1356,1357,1360],{"class":55,"line":368},[53,1358,1359],{"class":70},"    last_id: ",[53,1361,1362],{"class":63},"int\n",[53,1364,1365],{"class":55,"line":374},[53,1366,78],{"emptyLinePlaceholder":77},[53,1368,1369],{"class":55,"line":392},[53,1370,78],{"emptyLinePlaceholder":77},[53,1372,1373,1375,1377,1379,1381],{"class":55,"line":406},[53,1374,308],{"class":70},[53,1376,203],{"class":59},[53,1378,313],{"class":70},[53,1380,316],{"class":206},[53,1382,244],{"class":70},[53,1384,1385,1387,1389],{"class":55,"line":412},[53,1386,329],{"class":63},[53,1388,332],{"class":59},[53,1390,335],{"class":63},[53,1392,1393],{"class":55,"line":418},[53,1394,78],{"emptyLinePlaceholder":77},[53,1396,1397],{"class":55,"line":428},[53,1398,78],{"emptyLinePlaceholder":77},[53,1400,1401,1403,1405,1408],{"class":55,"line":447},[53,1402,351],{"class":59},[53,1404,354],{"class":59},[53,1406,1407],{"class":153}," paginate_orders",[53,1409,707],{"class":70},[53,1411,1412,1415,1417,1419],{"class":55,"line":453},[53,1413,1414],{"class":70},"    cursor: Optional[PageCursor] ",[53,1416,203],{"class":59},[53,1418,717],{"class":63},[53,1420,720],{"class":70},[53,1422,1423],{"class":55,"line":777},[53,1424,1425],{"class":70},") -> tuple[list[Order], Optional[PageCursor]]:\n",[53,1427,1428],{"class":55,"line":809},[53,1429,1430],{"class":206},"    \"\"\"Return one page of Orders and a cursor for the next page.\"\"\"\n",[53,1432,1433,1435,1437,1439,1441],{"class":55,"line":815},[53,1434,377],{"class":59},[53,1436,380],{"class":59},[53,1438,383],{"class":70},[53,1440,386],{"class":59},[53,1442,389],{"class":70},[53,1444,1445,1447,1449,1451,1453],{"class":55,"line":826},[53,1446,761],{"class":70},[53,1448,203],{"class":59},[53,1450,766],{"class":70},[53,1452,329],{"class":63},[53,1454,244],{"class":70},[53,1456,1457,1459,1462,1464,1466,1468],{"class":55,"line":838},[53,1458,780],{"class":59},[53,1460,1461],{"class":70}," cursor ",[53,1463,786],{"class":59},[53,1465,789],{"class":59},[53,1467,717],{"class":63},[53,1469,806],{"class":70},[53,1471,1472,1474,1476],{"class":55,"line":844},[53,1473,818],{"class":70},[53,1475,203],{"class":59},[53,1477,823],{"class":70},[53,1479,1480],{"class":55,"line":849},[53,1481,1482],{"class":70},"                tuple_(Order.created_at, Order.id)\n",[53,1484,1485,1488],{"class":55,"line":861},[53,1486,1487],{"class":59},"                >",[53,1489,1490],{"class":70}," (cursor.last_created_at, cursor.last_id)\n",[53,1492,1493],{"class":55,"line":873},[53,1494,841],{"class":70},[53,1496,1498,1500,1502,1504],{"class":55,"line":1497},47,[53,1499,395],{"class":70},[53,1501,203],{"class":59},[53,1503,400],{"class":59},[53,1505,858],{"class":70},[53,1507,1509,1511,1513,1515],{"class":55,"line":1508},48,[53,1510,864],{"class":70},[53,1512,203],{"class":59},[53,1514,459],{"class":63},[53,1516,462],{"class":70},[53,1518,1520],{"class":55,"line":1519},49,[53,1521,78],{"emptyLinePlaceholder":77},[53,1523,1525,1528,1530],{"class":55,"line":1524},50,[53,1526,1527],{"class":70},"    next_cursor: Optional[PageCursor] ",[53,1529,203],{"class":59},[53,1531,1532],{"class":63}," None\n",[53,1534,1536,1539,1542,1545,1548,1550],{"class":55,"line":1535},51,[53,1537,1538],{"class":59},"    if",[53,1540,1541],{"class":63}," len",[53,1543,1544],{"class":70},"(rows) ",[53,1546,1547],{"class":59},"==",[53,1549,437],{"class":63},[53,1551,806],{"class":70},[53,1553,1555,1558,1560,1563,1566,1569],{"class":55,"line":1554},52,[53,1556,1557],{"class":70},"        last ",[53,1559,203],{"class":59},[53,1561,1562],{"class":70}," rows[",[53,1564,1565],{"class":59},"-",[53,1567,1568],{"class":63},"1",[53,1570,1571],{"class":70},"]\n",[53,1573,1575,1578,1580,1583,1585,1587,1590,1592,1594],{"class":55,"line":1574},53,[53,1576,1577],{"class":70},"        next_cursor ",[53,1579,203],{"class":59},[53,1581,1582],{"class":70}," PageCursor(",[53,1584,1125],{"class":235},[53,1586,203],{"class":59},[53,1588,1589],{"class":70},"last.created_at, ",[53,1591,1129],{"class":235},[53,1593,203],{"class":59},[53,1595,1596],{"class":70},"last.id)\n",[53,1598,1600],{"class":55,"line":1599},54,[53,1601,78],{"emptyLinePlaceholder":77},[53,1603,1605,1608],{"class":55,"line":1604},55,[53,1606,1607],{"class":59},"    return",[53,1609,1610],{"class":70}," rows, next_cursor\n",[14,1612,1613,1614,1618,1619,1622],{},"This approach complements server-side cursor streaming covered in ",[27,1615,1617],{"href":1616},"\u002Fadvanced-query-patterns-and-bulk-data-operations\u002Fstreaming-large-result-sets-with-yield-per\u002Fusing-yield-per-to-stream-millions-of-rows-in-async\u002F","Using yield_per to Stream Millions of Rows in Async",". Use keyset pagination when callers need page-by-page access with a resumable cursor (API responses, exports triggered by HTTP requests). Use ",[18,1620,1621],{},"yield_per"," when the consuming code processes all rows in a single long-lived connection without exposing page tokens externally.",[880,1624],{},[33,1626,1628],{"id":1627},"resolving-warnings-errors-common-mistakes","Resolving Warnings, Errors & Common Mistakes",[1630,1631,1632,1648],"table",{},[1633,1634,1635],"thead",{},[1636,1637,1638,1642,1645],"tr",{},[1639,1640,1641],"th",{},"Warning \u002F Error",[1639,1643,1644],{},"Root Cause",[1639,1646,1647],{},"Production Fix",[1649,1650,1651,1682,1717,1747,1773,1793],"tbody",{},[1636,1652,1653,1659,1668],{},[1654,1655,1656],"td",{},[40,1657,1658],{},"Rows silently duplicated or skipped between pages",[1654,1660,1661,1664,1665,1667],{},[18,1662,1663],{},"ORDER BY created_at"," without a unique tie-breaker. Two rows sharing the same ",[18,1666,1106],{}," value straddle the keyset boundary unpredictably.",[1654,1669,1670,1671,1673,1674,1677,1678,1681],{},"Always append a unique column — typically ",[18,1672,1102],{}," — as the final sort key and include it in the keyset: ",[18,1675,1676],{},"ORDER BY created_at, id"," and ",[18,1679,1680],{},"WHERE (created_at, id) > (:ts, :id)",".",[1636,1683,1684,1691,1702],{},[1654,1685,1686],{},[40,1687,1688],{},[18,1689,1690],{},"sqlalchemy.exc.ProgrammingError: column \"...\" does not exist",[1654,1692,1693,1694,1697,1698,1701],{},"A column referenced in ",[18,1695,1696],{},"ORDER BY"," or the keyset ",[18,1699,1700],{},"WHERE"," clause is absent from the mapped table or not projected by the query. This occurs when the ORM class is out of sync with the schema.",[1654,1703,1704,1705,1708,1709,1712,1713,1716],{},"Run ",[18,1706,1707],{},"alembic upgrade head"," to apply pending migrations. Verify ",[18,1710,1711],{},"Order.__tablename__"," reflects the correct table. Use ",[18,1714,1715],{},"inspect(engine)"," to confirm column presence at startup.",[1636,1718,1719,1724,1733],{},[1654,1720,1721],{},[40,1722,1723],{},"Index not used — sequential scan on large table",[1654,1725,1726,1727,1729,1730,1732],{},"No composite index on the keyset columns, or only a single-column index on ",[18,1728,1106],{}," without ",[18,1731,1102],{},". PostgreSQL falls back to a sequential scan when it cannot satisfy both sort columns from one index.",[1654,1734,1735,1736,1739,1740,1743,1744,1681],{},"Create ",[18,1737,1738],{},"CREATE INDEX idx_orders_created_at_id ON orders (created_at, id);",". Confirm with ",[18,1741,1742],{},"EXPLAIN (ANALYZE, BUFFERS)"," that the plan shows ",[18,1745,1746],{},"Index Scan using idx_orders_created_at_id",[1636,1748,1749,1756,1763],{},[1654,1750,1751],{},[40,1752,1753,1755],{},[18,1754,20],{}," on 10 M-row table causing 30+ second queries",[1654,1757,1758,1759,1762],{},"Legacy pagination code still using ",[18,1760,1761],{},".offset(page * size)",". Each call forces Postgres to read and discard millions of rows before returning the requested page.",[1654,1764,1765,1766,1769,1770,1772],{},"Replace ",[18,1767,1768],{},".offset()"," with keyset ",[18,1771,1680],{},". For one-time migrations where random-access pages are unavoidable, consider materialised cursor tables.",[1636,1774,1775,1780,1786],{},[1654,1776,1777],{},[40,1778,1779],{},"Keyset returns wrong results after mid-pagination inserts",[1654,1781,1782,1783,1785],{},"New rows inserted with ",[18,1784,1106],{}," values earlier than the current cursor position are invisible; rows inserted with values equal to the last keyset boundary can appear twice.",[1654,1787,1788,1789,1792],{},"This is expected and correct for append-only workloads. For mutable datasets requiring strict consistency, snapshot the dataset into a temporary table or use ",[18,1790,1791],{},"FOR UPDATE SKIP LOCKED"," with a cursor queue.",[1636,1794,1795,1800,1806],{},[1654,1796,1797],{},[40,1798,1799],{},"Non-deterministic ordering breaks keyset stability",[1654,1801,1802,1805],{},[18,1803,1804],{},"ORDER BY RANDOM()"," or any non-deterministic expression makes the keyset meaningless — different executions return different orderings for the same cursor value.",[1654,1807,1808,1809,1811,1812,1126,1814,1816],{},"Keyset pagination requires a stable, deterministic ",[18,1810,1696],{},". Use immutable columns (e.g., ",[18,1813,1106],{},[18,1815,1102],{},") and never randomise the sort in a paginated query.",[880,1818],{},[33,1820,1822],{"id":1821},"advanced-keyset-pagination-optimization","Advanced Keyset Pagination Optimization",[887,1824,1826],{"id":1825},"bidirectional-pagination-with-a-dual-cursor","Bidirectional pagination with a dual-cursor",[14,1828,1829,1830,1677,1833,1836],{},"Most REST APIs expose both ",[18,1831,1832],{},"next",[18,1834,1835],{},"previous"," page tokens. Implementing backward navigation with keyset pagination requires reversing the sort direction while keeping the index usable.",[14,1838,1839,1840,1842],{},"The trick is to reverse the comparison operator and the ",[18,1841,1696],{}," direction for the previous-page fetch, then reverse the result list before returning it to the caller. This avoids storing the full result set and keeps both directions O(log N).",[44,1844,1846],{"className":46,"code":1845,"language":48,"meta":49,"style":49},"from __future__ import annotations\n\nimport asyncio\nimport datetime\nfrom dataclasses import dataclass\nfrom typing import Optional\n\nfrom sqlalchemy import select, tuple_\nfrom sqlalchemy.ext.asyncio import AsyncSession, create_async_engine\nfrom sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column\n\n\nclass Base(DeclarativeBase):\n    pass\n\n\nclass Order(Base):\n    __tablename__ = \"orders\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    created_at: Mapped[datetime.datetime] = mapped_column()\n    customer_id: Mapped[int] = mapped_column()\n    total_cents: Mapped[int] = mapped_column()\n\n\nengine = create_async_engine(\"postgresql+asyncpg:\u002F\u002Fuser:pass@host\u002Fdb\")\nPAGE_SIZE = 100\n\n\nasync def fetch_previous_page(\n    before_created_at: datetime.datetime,\n    before_id: int,\n) -> list[Order]:\n    \"\"\"\n    Fetch the page of Orders immediately before the given cursor.\n    Reverses ORDER BY to seek backwards, then reverses results to\n    restore ascending order for the caller.\n    \"\"\"\n    async with AsyncSession(engine) as session:\n        stmt = (\n            select(Order)\n            # Reverse sort for the backward seek\n            .order_by(Order.created_at.desc(), Order.id.desc())\n            .where(\n                tuple_(Order.created_at, Order.id) \u003C (before_created_at, before_id)\n            )\n            .limit(PAGE_SIZE)\n        )\n        result = await session.execute(stmt)\n        rows = list(result.scalars())\n\n    # Re-invert to ascending order before returning\n    rows.reverse()\n    return rows\n",[18,1847,1848,1858,1862,1868,1874,1884,1894,1898,1908,1918,1928,1932,1936,1948,1952,1956,1960,1972,1980,1984,2006,2014,2026,2038,2042,2046,2058,2066,2070,2074,2085,2090,2099,2103,2108,2113,2118,2123,2127,2139,2148,2152,2157,2162,2167,2177,2181,2189,2193,2203,2213,2217,2222,2227],{"__ignoreMap":49},[53,1849,1850,1852,1854,1856],{"class":55,"line":56},[53,1851,60],{"class":59},[53,1853,64],{"class":63},[53,1855,67],{"class":59},[53,1857,71],{"class":70},[53,1859,1860],{"class":55,"line":74},[53,1861,78],{"emptyLinePlaceholder":77},[53,1863,1864,1866],{"class":55,"line":81},[53,1865,84],{"class":59},[53,1867,87],{"class":70},[53,1869,1870,1872],{"class":55,"line":90},[53,1871,84],{"class":59},[53,1873,134],{"class":70},[53,1875,1876,1878,1880,1882],{"class":55,"line":103},[53,1877,60],{"class":59},[53,1879,1168],{"class":70},[53,1881,84],{"class":59},[53,1883,1173],{"class":70},[53,1885,1886,1888,1890,1892],{"class":55,"line":116},[53,1887,60],{"class":59},[53,1889,505],{"class":70},[53,1891,84],{"class":59},[53,1893,510],{"class":70},[53,1895,1896],{"class":55,"line":129},[53,1897,78],{"emptyLinePlaceholder":77},[53,1899,1900,1902,1904,1906],{"class":55,"line":137},[53,1901,60],{"class":59},[53,1903,95],{"class":70},[53,1905,84],{"class":59},[53,1907,525],{"class":70},[53,1909,1910,1912,1914,1916],{"class":55,"line":142},[53,1911,60],{"class":59},[53,1913,108],{"class":70},[53,1915,84],{"class":59},[53,1917,113],{"class":70},[53,1919,1920,1922,1924,1926],{"class":55,"line":147},[53,1921,60],{"class":59},[53,1923,121],{"class":70},[53,1925,84],{"class":59},[53,1927,126],{"class":70},[53,1929,1930],{"class":55,"line":166},[53,1931,78],{"emptyLinePlaceholder":77},[53,1933,1934],{"class":55,"line":172},[53,1935,78],{"emptyLinePlaceholder":77},[53,1937,1938,1940,1942,1944,1946],{"class":55,"line":177},[53,1939,150],{"class":59},[53,1941,154],{"class":153},[53,1943,157],{"class":70},[53,1945,160],{"class":153},[53,1947,163],{"class":70},[53,1949,1950],{"class":55,"line":182},[53,1951,169],{"class":59},[53,1953,1954],{"class":55,"line":197},[53,1955,78],{"emptyLinePlaceholder":77},[53,1957,1958],{"class":55,"line":210},[53,1959,78],{"emptyLinePlaceholder":77},[53,1961,1962,1964,1966,1968,1970],{"class":55,"line":215},[53,1963,150],{"class":59},[53,1965,187],{"class":153},[53,1967,157],{"class":70},[53,1969,192],{"class":153},[53,1971,163],{"class":70},[53,1973,1974,1976,1978],{"class":55,"line":247},[53,1975,200],{"class":70},[53,1977,203],{"class":59},[53,1979,207],{"class":206},[53,1981,1982],{"class":55,"line":266},[53,1983,78],{"emptyLinePlaceholder":77},[53,1985,1986,1988,1990,1992,1994,1996,1998,2000,2002,2004],{"class":55,"line":281},[53,1987,218],{"class":63},[53,1989,221],{"class":70},[53,1991,224],{"class":63},[53,1993,227],{"class":70},[53,1995,203],{"class":59},[53,1997,232],{"class":70},[53,1999,236],{"class":235},[53,2001,203],{"class":59},[53,2003,241],{"class":63},[53,2005,244],{"class":70},[53,2007,2008,2010,2012],{"class":55,"line":295},[53,2009,250],{"class":70},[53,2011,203],{"class":59},[53,2013,278],{"class":70},[53,2015,2016,2018,2020,2022,2024],{"class":55,"line":300},[53,2017,269],{"class":70},[53,2019,224],{"class":63},[53,2021,227],{"class":70},[53,2023,203],{"class":59},[53,2025,278],{"class":70},[53,2027,2028,2030,2032,2034,2036],{"class":55,"line":305},[53,2029,284],{"class":70},[53,2031,224],{"class":63},[53,2033,227],{"class":70},[53,2035,203],{"class":59},[53,2037,278],{"class":70},[53,2039,2040],{"class":55,"line":321},[53,2041,78],{"emptyLinePlaceholder":77},[53,2043,2044],{"class":55,"line":326},[53,2045,78],{"emptyLinePlaceholder":77},[53,2047,2048,2050,2052,2054,2056],{"class":55,"line":338},[53,2049,308],{"class":70},[53,2051,203],{"class":59},[53,2053,313],{"class":70},[53,2055,316],{"class":206},[53,2057,244],{"class":70},[53,2059,2060,2062,2064],{"class":55,"line":343},[53,2061,329],{"class":63},[53,2063,332],{"class":59},[53,2065,335],{"class":63},[53,2067,2068],{"class":55,"line":348},[53,2069,78],{"emptyLinePlaceholder":77},[53,2071,2072],{"class":55,"line":368},[53,2073,78],{"emptyLinePlaceholder":77},[53,2075,2076,2078,2080,2083],{"class":55,"line":374},[53,2077,351],{"class":59},[53,2079,354],{"class":59},[53,2081,2082],{"class":153}," fetch_previous_page",[53,2084,707],{"class":70},[53,2086,2087],{"class":55,"line":392},[53,2088,2089],{"class":70},"    before_created_at: datetime.datetime,\n",[53,2091,2092,2095,2097],{"class":55,"line":406},[53,2093,2094],{"class":70},"    before_id: ",[53,2096,224],{"class":63},[53,2098,720],{"class":70},[53,2100,2101],{"class":55,"line":412},[53,2102,365],{"class":70},[53,2104,2105],{"class":55,"line":418},[53,2106,2107],{"class":206},"    \"\"\"\n",[53,2109,2110],{"class":55,"line":428},[53,2111,2112],{"class":206},"    Fetch the page of Orders immediately before the given cursor.\n",[53,2114,2115],{"class":55,"line":447},[53,2116,2117],{"class":206},"    Reverses ORDER BY to seek backwards, then reverses results to\n",[53,2119,2120],{"class":55,"line":453},[53,2121,2122],{"class":206},"    restore ascending order for the caller.\n",[53,2124,2125],{"class":55,"line":777},[53,2126,2107],{"class":206},[53,2128,2129,2131,2133,2135,2137],{"class":55,"line":809},[53,2130,377],{"class":59},[53,2132,380],{"class":59},[53,2134,383],{"class":70},[53,2136,386],{"class":59},[53,2138,389],{"class":70},[53,2140,2141,2143,2145],{"class":55,"line":815},[53,2142,761],{"class":70},[53,2144,203],{"class":59},[53,2146,2147],{"class":70}," (\n",[53,2149,2150],{"class":55,"line":826},[53,2151,409],{"class":70},[53,2153,2154],{"class":55,"line":838},[53,2155,2156],{"class":443},"            # Reverse sort for the backward seek\n",[53,2158,2159],{"class":55,"line":844},[53,2160,2161],{"class":70},"            .order_by(Order.created_at.desc(), Order.id.desc())\n",[53,2163,2164],{"class":55,"line":849},[53,2165,2166],{"class":70},"            .where(\n",[53,2168,2169,2171,2174],{"class":55,"line":861},[53,2170,829],{"class":70},[53,2172,2173],{"class":59},"\u003C",[53,2175,2176],{"class":70}," (before_created_at, before_id)\n",[53,2178,2179],{"class":55,"line":873},[53,2180,841],{"class":70},[53,2182,2183,2185,2187],{"class":55,"line":1497},[53,2184,421],{"class":70},[53,2186,329],{"class":63},[53,2188,244],{"class":70},[53,2190,2191],{"class":55,"line":1508},[53,2192,450],{"class":70},[53,2194,2195,2197,2199,2201],{"class":55,"line":1519},[53,2196,395],{"class":70},[53,2198,203],{"class":59},[53,2200,400],{"class":59},[53,2202,858],{"class":70},[53,2204,2205,2207,2209,2211],{"class":55,"line":1524},[53,2206,864],{"class":70},[53,2208,203],{"class":59},[53,2210,459],{"class":63},[53,2212,462],{"class":70},[53,2214,2215],{"class":55,"line":1535},[53,2216,78],{"emptyLinePlaceholder":77},[53,2218,2219],{"class":55,"line":1554},[53,2220,2221],{"class":443},"    # Re-invert to ascending order before returning\n",[53,2223,2224],{"class":55,"line":1574},[53,2225,2226],{"class":70},"    rows.reverse()\n",[53,2228,2229,2231],{"class":55,"line":1599},[53,2230,1607],{"class":59},[53,2232,878],{"class":70},[887,2234,2236],{"id":2235},"covering-index-to-eliminate-heap-fetches","Covering index to eliminate heap fetches",[14,2238,2239,2240,1126,2242,1126,2244,2247],{},"When the API only returns a subset of columns (e.g., ",[18,2241,1102],{},[18,2243,1106],{},[18,2245,2246],{},"total_cents"," for an order list view), a covering index that includes those columns lets Postgres satisfy the entire query from the index without visiting the heap at all. This further reduces I\u002FO on large tables.",[44,2249,2253],{"className":2250,"code":2251,"language":2252,"meta":49,"style":49},"language-sql shiki shiki-themes github-light github-dark","-- Covering index: satisfies ORDER BY, keyset WHERE, and projected columns\n-- without any heap access for the common list-view query.\nCREATE INDEX idx_orders_keyset_covering\n    ON orders (created_at, id)\n    INCLUDE (customer_id, total_cents);\n","sql",[18,2254,2255,2260,2265,2276,2284],{"__ignoreMap":49},[53,2256,2257],{"class":55,"line":56},[53,2258,2259],{"class":443},"-- Covering index: satisfies ORDER BY, keyset WHERE, and projected columns\n",[53,2261,2262],{"class":55,"line":74},[53,2263,2264],{"class":443},"-- without any heap access for the common list-view query.\n",[53,2266,2267,2270,2273],{"class":55,"line":81},[53,2268,2269],{"class":59},"CREATE",[53,2271,2272],{"class":59}," INDEX",[53,2274,2275],{"class":153}," idx_orders_keyset_covering\n",[53,2277,2278,2281],{"class":55,"line":90},[53,2279,2280],{"class":59},"    ON",[53,2282,2283],{"class":70}," orders (created_at, id)\n",[53,2285,2286,2289],{"class":55,"line":103},[53,2287,2288],{"class":59},"    INCLUDE",[53,2290,2291],{"class":70}," (customer_id, total_cents);\n",[14,2293,2294,2295,2298,2299,2302],{},"SQLAlchemy can take advantage of this automatically — no ORM changes needed. Verify with ",[18,2296,2297],{},"EXPLAIN (ANALYZE)",": the plan should show ",[18,2300,2301],{},"Index Only Scan"," with zero heap fetches once the visibility map is current.",[887,2304,2306],{"id":2305},"nullable-columns-in-the-keyset-with-nulls-last","Nullable columns in the keyset with NULLS LAST",[14,2308,2309,2310,2313,2314,2317,2318,2321],{},"When sorting by an optional column such as ",[18,2311,2312],{},"Invoice.settled_at",", null values require explicit handling. PostgreSQL defaults to ",[18,2315,2316],{},"NULLS LAST"," in ascending order and ",[18,2319,2320],{},"NULLS FIRST"," in descending order, but the keyset comparison must match the sort order exactly or results will be inconsistent.",[44,2323,2325],{"className":46,"code":2324,"language":48,"meta":49,"style":49},"from __future__ import annotations\n\nimport asyncio\nimport datetime\nfrom typing import Optional\n\nfrom sqlalchemy import select, tuple_, nulls_last\nfrom sqlalchemy.ext.asyncio import AsyncSession, create_async_engine\nfrom sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column\n\n\nclass Base(DeclarativeBase):\n    pass\n\n\nclass Invoice(Base):\n    __tablename__ = \"invoices\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    settled_at: Mapped[Optional[datetime.datetime]] = mapped_column(nullable=True)\n    customer_id: Mapped[int] = mapped_column()\n    amount_cents: Mapped[int] = mapped_column()\n\n\nengine = create_async_engine(\"postgresql+asyncpg:\u002F\u002Fuser:pass@host\u002Fdb\")\nPAGE_SIZE = 100\n\n\nasync def fetch_invoices_nullable_keyset(\n    last_settled_at: Optional[datetime.datetime],\n    last_id: int,\n) -> list[Invoice]:\n    \"\"\"\n    Paginate invoices sorted by nullable settled_at (NULLS LAST) then id.\n    Rows where settled_at IS NULL appear at the end of the result set.\n    \"\"\"\n    async with AsyncSession(engine) as session:\n        stmt = (\n            select(Invoice)\n            .order_by(nulls_last(Invoice.settled_at.asc()), Invoice.id)\n            .limit(PAGE_SIZE)\n        )\n\n        if last_settled_at is not None:\n            # Settled rows: standard tuple comparison works\n            stmt = stmt.where(\n                tuple_(Invoice.settled_at, Invoice.id) > (last_settled_at, last_id)\n            )\n        else:\n            # Cursor is within the NULL partition — filter by id only\n            stmt = stmt.where(\n                Invoice.settled_at.is_(None),\n                Invoice.id > last_id,\n            )\n\n        result = await session.execute(stmt)\n        return list(result.scalars())\n",[18,2326,2327,2337,2341,2347,2353,2363,2367,2378,2388,2398,2402,2406,2418,2422,2426,2430,2443,2452,2456,2478,2496,2508,2521,2525,2529,2541,2549,2553,2557,2568,2573,2581,2586,2590,2595,2600,2604,2616,2624,2629,2634,2642,2646,2650,2665,2670,2678,2688,2692,2699,2704,2712,2723,2733,2737,2741,2752],{"__ignoreMap":49},[53,2328,2329,2331,2333,2335],{"class":55,"line":56},[53,2330,60],{"class":59},[53,2332,64],{"class":63},[53,2334,67],{"class":59},[53,2336,71],{"class":70},[53,2338,2339],{"class":55,"line":74},[53,2340,78],{"emptyLinePlaceholder":77},[53,2342,2343,2345],{"class":55,"line":81},[53,2344,84],{"class":59},[53,2346,87],{"class":70},[53,2348,2349,2351],{"class":55,"line":90},[53,2350,84],{"class":59},[53,2352,134],{"class":70},[53,2354,2355,2357,2359,2361],{"class":55,"line":103},[53,2356,60],{"class":59},[53,2358,505],{"class":70},[53,2360,84],{"class":59},[53,2362,510],{"class":70},[53,2364,2365],{"class":55,"line":116},[53,2366,78],{"emptyLinePlaceholder":77},[53,2368,2369,2371,2373,2375],{"class":55,"line":129},[53,2370,60],{"class":59},[53,2372,95],{"class":70},[53,2374,84],{"class":59},[53,2376,2377],{"class":70}," select, tuple_, nulls_last\n",[53,2379,2380,2382,2384,2386],{"class":55,"line":137},[53,2381,60],{"class":59},[53,2383,108],{"class":70},[53,2385,84],{"class":59},[53,2387,113],{"class":70},[53,2389,2390,2392,2394,2396],{"class":55,"line":142},[53,2391,60],{"class":59},[53,2393,121],{"class":70},[53,2395,84],{"class":59},[53,2397,126],{"class":70},[53,2399,2400],{"class":55,"line":147},[53,2401,78],{"emptyLinePlaceholder":77},[53,2403,2404],{"class":55,"line":166},[53,2405,78],{"emptyLinePlaceholder":77},[53,2407,2408,2410,2412,2414,2416],{"class":55,"line":172},[53,2409,150],{"class":59},[53,2411,154],{"class":153},[53,2413,157],{"class":70},[53,2415,160],{"class":153},[53,2417,163],{"class":70},[53,2419,2420],{"class":55,"line":177},[53,2421,169],{"class":59},[53,2423,2424],{"class":55,"line":182},[53,2425,78],{"emptyLinePlaceholder":77},[53,2427,2428],{"class":55,"line":197},[53,2429,78],{"emptyLinePlaceholder":77},[53,2431,2432,2434,2437,2439,2441],{"class":55,"line":210},[53,2433,150],{"class":59},[53,2435,2436],{"class":153}," Invoice",[53,2438,157],{"class":70},[53,2440,192],{"class":153},[53,2442,163],{"class":70},[53,2444,2445,2447,2449],{"class":55,"line":215},[53,2446,200],{"class":70},[53,2448,203],{"class":59},[53,2450,2451],{"class":206}," \"invoices\"\n",[53,2453,2454],{"class":55,"line":247},[53,2455,78],{"emptyLinePlaceholder":77},[53,2457,2458,2460,2462,2464,2466,2468,2470,2472,2474,2476],{"class":55,"line":266},[53,2459,218],{"class":63},[53,2461,221],{"class":70},[53,2463,224],{"class":63},[53,2465,227],{"class":70},[53,2467,203],{"class":59},[53,2469,232],{"class":70},[53,2471,236],{"class":235},[53,2473,203],{"class":59},[53,2475,241],{"class":63},[53,2477,244],{"class":70},[53,2479,2480,2483,2485,2487,2490,2492,2494],{"class":55,"line":281},[53,2481,2482],{"class":70},"    settled_at: Mapped[Optional[datetime.datetime]] ",[53,2484,203],{"class":59},[53,2486,232],{"class":70},[53,2488,2489],{"class":235},"nullable",[53,2491,203],{"class":59},[53,2493,241],{"class":63},[53,2495,244],{"class":70},[53,2497,2498,2500,2502,2504,2506],{"class":55,"line":295},[53,2499,269],{"class":70},[53,2501,224],{"class":63},[53,2503,227],{"class":70},[53,2505,203],{"class":59},[53,2507,278],{"class":70},[53,2509,2510,2513,2515,2517,2519],{"class":55,"line":300},[53,2511,2512],{"class":70},"    amount_cents: Mapped[",[53,2514,224],{"class":63},[53,2516,227],{"class":70},[53,2518,203],{"class":59},[53,2520,278],{"class":70},[53,2522,2523],{"class":55,"line":305},[53,2524,78],{"emptyLinePlaceholder":77},[53,2526,2527],{"class":55,"line":321},[53,2528,78],{"emptyLinePlaceholder":77},[53,2530,2531,2533,2535,2537,2539],{"class":55,"line":326},[53,2532,308],{"class":70},[53,2534,203],{"class":59},[53,2536,313],{"class":70},[53,2538,316],{"class":206},[53,2540,244],{"class":70},[53,2542,2543,2545,2547],{"class":55,"line":338},[53,2544,329],{"class":63},[53,2546,332],{"class":59},[53,2548,335],{"class":63},[53,2550,2551],{"class":55,"line":343},[53,2552,78],{"emptyLinePlaceholder":77},[53,2554,2555],{"class":55,"line":348},[53,2556,78],{"emptyLinePlaceholder":77},[53,2558,2559,2561,2563,2566],{"class":55,"line":368},[53,2560,351],{"class":59},[53,2562,354],{"class":59},[53,2564,2565],{"class":153}," fetch_invoices_nullable_keyset",[53,2567,707],{"class":70},[53,2569,2570],{"class":55,"line":374},[53,2571,2572],{"class":70},"    last_settled_at: Optional[datetime.datetime],\n",[53,2574,2575,2577,2579],{"class":55,"line":392},[53,2576,1359],{"class":70},[53,2578,224],{"class":63},[53,2580,720],{"class":70},[53,2582,2583],{"class":55,"line":406},[53,2584,2585],{"class":70},") -> list[Invoice]:\n",[53,2587,2588],{"class":55,"line":412},[53,2589,2107],{"class":206},[53,2591,2592],{"class":55,"line":418},[53,2593,2594],{"class":206},"    Paginate invoices sorted by nullable settled_at (NULLS LAST) then id.\n",[53,2596,2597],{"class":55,"line":428},[53,2598,2599],{"class":206},"    Rows where settled_at IS NULL appear at the end of the result set.\n",[53,2601,2602],{"class":55,"line":447},[53,2603,2107],{"class":206},[53,2605,2606,2608,2610,2612,2614],{"class":55,"line":453},[53,2607,377],{"class":59},[53,2609,380],{"class":59},[53,2611,383],{"class":70},[53,2613,386],{"class":59},[53,2615,389],{"class":70},[53,2617,2618,2620,2622],{"class":55,"line":777},[53,2619,761],{"class":70},[53,2621,203],{"class":59},[53,2623,2147],{"class":70},[53,2625,2626],{"class":55,"line":809},[53,2627,2628],{"class":70},"            select(Invoice)\n",[53,2630,2631],{"class":55,"line":815},[53,2632,2633],{"class":70},"            .order_by(nulls_last(Invoice.settled_at.asc()), Invoice.id)\n",[53,2635,2636,2638,2640],{"class":55,"line":826},[53,2637,421],{"class":70},[53,2639,329],{"class":63},[53,2641,244],{"class":70},[53,2643,2644],{"class":55,"line":838},[53,2645,450],{"class":70},[53,2647,2648],{"class":55,"line":844},[53,2649,78],{"emptyLinePlaceholder":77},[53,2651,2652,2654,2657,2659,2661,2663],{"class":55,"line":849},[53,2653,780],{"class":59},[53,2655,2656],{"class":70}," last_settled_at ",[53,2658,786],{"class":59},[53,2660,789],{"class":59},[53,2662,717],{"class":63},[53,2664,806],{"class":70},[53,2666,2667],{"class":55,"line":861},[53,2668,2669],{"class":443},"            # Settled rows: standard tuple comparison works\n",[53,2671,2672,2674,2676],{"class":55,"line":873},[53,2673,818],{"class":70},[53,2675,203],{"class":59},[53,2677,823],{"class":70},[53,2679,2680,2683,2685],{"class":55,"line":1497},[53,2681,2682],{"class":70},"                tuple_(Invoice.settled_at, Invoice.id) ",[53,2684,832],{"class":59},[53,2686,2687],{"class":70}," (last_settled_at, last_id)\n",[53,2689,2690],{"class":55,"line":1508},[53,2691,841],{"class":70},[53,2693,2694,2697],{"class":55,"line":1519},[53,2695,2696],{"class":59},"        else",[53,2698,806],{"class":70},[53,2700,2701],{"class":55,"line":1524},[53,2702,2703],{"class":443},"            # Cursor is within the NULL partition — filter by id only\n",[53,2705,2706,2708,2710],{"class":55,"line":1535},[53,2707,818],{"class":70},[53,2709,203],{"class":59},[53,2711,823],{"class":70},[53,2713,2714,2717,2720],{"class":55,"line":1554},[53,2715,2716],{"class":70},"                Invoice.settled_at.is_(",[53,2718,2719],{"class":63},"None",[53,2721,2722],{"class":70},"),\n",[53,2724,2725,2728,2730],{"class":55,"line":1574},[53,2726,2727],{"class":70},"                Invoice.id ",[53,2729,832],{"class":59},[53,2731,2732],{"class":70}," last_id,\n",[53,2734,2735],{"class":55,"line":1599},[53,2736,841],{"class":70},[53,2738,2739],{"class":55,"line":1604},[53,2740,78],{"emptyLinePlaceholder":77},[53,2742,2744,2746,2748,2750],{"class":55,"line":2743},56,[53,2745,395],{"class":70},[53,2747,203],{"class":59},[53,2749,400],{"class":59},[53,2751,858],{"class":70},[53,2753,2755,2757,2759],{"class":55,"line":2754},57,[53,2756,456],{"class":59},[53,2758,459],{"class":63},[53,2760,462],{"class":70},[14,2762,2763,2764,2768,2769,2771],{},"This pattern also applies to ",[27,2765,2767],{"href":2766},"\u002Fadvanced-query-patterns-and-bulk-data-operations\u002Fwindow-functions-and-analytical-queries\u002F","Window Functions and Analytical Queries"," where ",[18,2770,2316],{}," ordering affects frame boundaries.",[880,2773],{},[33,2775,2777],{"id":2776},"frequently-asked-questions","Frequently Asked Questions",[14,2779,2780],{},[40,2781,2782],{},"Can I use keyset pagination with SQLAlchemy ORM relationships and joined eager loading?",[14,2784,2785,2786,2788,2789,2792,2793,2796,2797,2800,2801,2803,2804,2807,2808,2812],{},"Yes, but the keyset ",[18,2787,1700],{}," clause must reference columns on the primary entity being paginated, not on a joined relationship. Add ",[18,2790,2791],{},".options(selectinload(Order.line_items))"," after constructing the base keyset query. Avoid ",[18,2794,2795],{},"joinedload"," with keyset pagination — the ",[18,2798,2799],{},"LIMIT"," applies before the join is expanded, which causes ",[18,2802,2795],{}," to return incomplete relationship collections for the last row on each page. ",[18,2805,2806],{},"selectinload"," fires a second query keyed on the primary IDs returned and avoids this entirely. See ",[27,2809,2811],{"href":2810},"\u002Fadvanced-query-patterns-and-bulk-data-operations\u002Fcomplex-joins-and-relationship-loading-strategies\u002F","Complex Joins and Relationship Loading Strategies"," for a full comparison.",[14,2814,2815],{},[40,2816,2817],{},"When is OFFSET still acceptable?",[14,2819,2820,2821,2823,2824,2826],{},"For tables under roughly 50,000 rows, the performance difference is negligible and ",[18,2822,20],{}," keeps the implementation simple. ",[18,2825,20],{}," is also the only option when the caller needs true random page access (e.g., \"jump to page 47\") rather than sequential forward\u002Fbackward traversal. Keyset cursors are inherently sequential — they cannot skip to an arbitrary page without iterating through all preceding pages.",[14,2828,2829],{},[40,2830,2831],{},"How do I encode the cursor safely for an HTTP API?",[14,2833,2834,2835,2838,2839,2842],{},"Serialize ",[18,2836,2837],{},"(last_created_at.isoformat(), last_id)"," as a JSON string, then base64-encode it and sign it with ",[18,2840,2841],{},"itsdangerous.URLSafeTimedSerializer"," or similar. Never trust a raw cursor value from an untrusted client — validate that both fields are present and of the correct type before issuing the database query. An unsigned cursor allows callers to probe arbitrary database positions.",[14,2844,2845],{},[40,2846,2847,2848,2850],{},"Does keyset pagination work with ",[18,2849,1621],{}," for background batch processing?",[14,2852,2853,2854,2856,2857,2860,2861,2865,2866,2868],{},"They serve complementary roles. ",[18,2855,1621],{}," holds a server-side cursor open for the lifetime of a single long-running ",[18,2858,2859],{},"async with AsyncSession"," block and streams rows in memory-bounded chunks — ideal for ETL or data exports within a single process. Keyset pagination stores only the cursor value between independent requests, making it suitable for stateless API handlers or distributed workers that cannot share a database connection. When connection pool exhaustion is a concern — a risk covered in depth at ",[27,2862,2864],{"href":2863},"\u002Fasync-engines-dialects-and-connection-pooling\u002Fhandling-connection-leaks-and-pool-exhaustion\u002F","Handling Connection Leaks and Pool Exhaustion"," — keyset pagination releases the connection after each page fetch and is therefore safer under high concurrency than a long-lived ",[18,2867,1621],{}," cursor.",[880,2870],{},[33,2872,2874],{"id":2873},"related","Related",[2876,2877,2878,2884,2889,2896],"ul",{},[2879,2880,2881,2883],"li",{},[27,2882,30],{"href":29}," — parent cluster covering server-side cursor streaming for batch processing workloads",[2879,2885,2886,2888],{},[27,2887,1617],{"href":1616}," — when to use a persistent cursor instead of a keyset token",[2879,2890,2891,2895],{},[27,2892,2894],{"href":2893},"\u002Fadvanced-query-patterns-and-bulk-data-operations\u002Fhigh-performance-bulk-inserts-and-updates\u002F","High-Performance Bulk Inserts and Updates"," — efficient write patterns that complement large-table read strategies",[2879,2897,2898,2900],{},[27,2899,2864],{"href":2863}," — why releasing connections between keyset pages matters under concurrency",[2902,2903,2904],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":49,"searchDepth":74,"depth":74,"links":2906},[2907,2908,2913,2914,2919,2920],{"id":35,"depth":74,"text":36},{"id":884,"depth":74,"text":885,"children":2909},[2910,2911,2912],{"id":889,"depth":81,"text":890},{"id":1076,"depth":81,"text":1077},{"id":1110,"depth":81,"text":1111},{"id":1627,"depth":74,"text":1628},{"id":1821,"depth":74,"text":1822,"children":2915},[2916,2917,2918],{"id":1825,"depth":81,"text":1826},{"id":2235,"depth":81,"text":2236},{"id":2305,"depth":81,"text":2306},{"id":2776,"depth":74,"text":2777},{"id":2873,"depth":74,"text":2874},"Keyset pagination (also called seek pagination) replaces OFFSET with a WHERE (created_at, id) > (:last_ts, :last_id) clause so every page fetch is an O(log N) index seek instead of an O(N) sequential scan — this page belongs to the Streaming Large Result Sets with yield_per cluster.","md",{"date":2924},"2026-06-18","\u002Fadvanced-query-patterns-and-bulk-data-operations\u002Fstreaming-large-result-sets-with-yield-per\u002Fpaginating-large-result-sets-with-keyset-pagination",{"title":5,"description":2921},"advanced-query-patterns-and-bulk-data-operations\u002Fstreaming-large-result-sets-with-yield-per\u002Fpaginating-large-result-sets-with-keyset-pagination\u002Findex","8ikO8pkVqB5Rr1zNHds7TW-e48L_mVJbyseJIiy4Amg",1781810028980]