[{"data":1,"prerenderedAt":2383},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fcache-generated-excel-files-in-a-web-app":3,"surround:\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fcache-generated-excel-files-in-a-web-app":2351,"ancestors:\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fcache-generated-excel-files-in-a-web-app":2360,"children:\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fcache-generated-excel-files-in-a-web-app":2365,"related:\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fcache-generated-excel-files-in-a-web-app":2366},{"id":4,"title":5,"body":6,"dateModified":2317,"datePublished":2317,"description":2318,"extension":2319,"faq":2320,"keyword":2329,"kind":2330,"meta":2331,"navigation":103,"path":2340,"seo":2341,"slug":2344,"stem":2345,"topics":2346,"type":2349,"__hash__":2350},"docs\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fcache-generated-excel-files-in-a-web-app\u002Findex.md","Cache Generated Excel Files in a Web App",{"type":7,"value":8,"toc":2305},"minimark",[9,19,24,56,63,67,369,379,510,514,901,912,923,927,1435,1462,1465,1661,1664,1774,1778,2159,2168,2172,2182,2186,2227,2231,2234,2238,2247,2256,2262,2268,2272,2301],[10,11,12,13,18],"p",{},"An \"Export to Excel\" button is cheap to add and can be expensive to run. Each click queries the database, builds a DataFrame, writes a styled workbook and sends it — a few seconds of work for a big export, repeated every time anyone clicks, even when ten people download the same month's report in the same morning and the data has not changed since midnight. Caching the generated file fixes that: build it once, keep it on disk, and hand the same bytes to everyone who asks for the same thing until the data changes. The details are what make it correct — a cache key that changes when the data changes, files that are never served half-written, browsers told when their copy is still current, and a limit on how much disk the cache may use. This page builds that cache for a Flask download endpoint and tests it with Flask's test client. It belongs to ",[14,15,17],"a",{"href":16},"\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002F","Serving Excel Files from Python Web Apps",".",[20,21,23],"h2",{"id":22},"prerequisites","Prerequisites",[25,26,31],"pre",{"className":27,"code":28,"language":29,"meta":30,"style":30},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install flask pandas openpyxl\n","bash","",[32,33,34],"code",{"__ignoreMap":30},[35,36,39,43,47,50,53],"span",{"class":37,"line":38},"line",1,[35,40,42],{"class":41},"sMTad","pip",[35,44,46],{"class":45},"srMev"," install",[35,48,49],{"class":45}," flask",[35,51,52],{"class":45}," pandas",[35,54,55],{"class":45}," openpyxl\n",[10,57,58,59,62],{},"The same design works in FastAPI or Django: only the request and response objects differ. The example data lives in SQLite, with an ",[32,60,61],{},"updated_at"," column that the cache uses to notice changes.",[20,64,66],{"id":65},"step-1-a-cache-key-that-changes-when-the-data-does","Step 1 — A cache key that changes when the data does",[25,68,72],{"className":69,"code":70,"language":71,"meta":30,"style":30},"language-python shiki shiki-themes github-light github-dark-high-contrast","import hashlib, json, sqlite3\nfrom contextlib import closing\n\nDB = \"sales.sqlite\"\n\ndef data_version():\n    \"\"\"Cheap fingerprint of the data: last update time and row count.\"\"\"\n    with closing(sqlite3.connect(DB)) as con:\n        return con.execute(\"SELECT MAX(updated_at) || '\u002F' || COUNT(*) FROM sales\").fetchone()[0]\n\ndef cache_key(report, params, version, code_version=\"v3\"):\n    payload = json.dumps({\"report\": report, \"params\": params, \"data\": version, \"code\": code_version}, sort_keys=True)\n    return hashlib.sha256(payload.encode()).hexdigest()[:32]\n\nv = data_version()\nprint(\"data version:\", v)\nprint(cache_key(\"sales\", {\"month\": \"2026-09\", \"region\": \"North\"}, v))\nprint(cache_key(\"sales\", {\"region\": \"North\", \"month\": \"2026-09\"}, v))     # same key, order does not matter\n","python",[32,73,74,84,98,105,118,123,136,142,162,183,188,208,255,269,274,285,300,337],{"__ignoreMap":30},[35,75,76,80],{"class":37,"line":38},[35,77,79],{"class":78},"s-kum","import",[35,81,83],{"class":82},"skGVy"," hashlib, json, sqlite3\n",[35,85,87,90,93,95],{"class":37,"line":86},2,[35,88,89],{"class":78},"from",[35,91,92],{"class":82}," contextlib ",[35,94,79],{"class":78},[35,96,97],{"class":82}," closing\n",[35,99,101],{"class":37,"line":100},3,[35,102,104],{"emptyLinePlaceholder":103},true,"\n",[35,106,108,112,115],{"class":37,"line":107},4,[35,109,111],{"class":110},"sP0c6","DB",[35,113,114],{"class":78}," =",[35,116,117],{"class":45}," \"sales.sqlite\"\n",[35,119,121],{"class":37,"line":120},5,[35,122,104],{"emptyLinePlaceholder":103},[35,124,126,129,133],{"class":37,"line":125},6,[35,127,128],{"class":78},"def",[35,130,132],{"class":131},"s_Opv"," data_version",[35,134,135],{"class":82},"():\n",[35,137,139],{"class":37,"line":138},7,[35,140,141],{"class":45},"    \"\"\"Cheap fingerprint of the data: last update time and row count.\"\"\"\n",[35,143,145,148,151,153,156,159],{"class":37,"line":144},8,[35,146,147],{"class":78},"    with",[35,149,150],{"class":82}," closing(sqlite3.connect(",[35,152,111],{"class":110},[35,154,155],{"class":82},")) ",[35,157,158],{"class":78},"as",[35,160,161],{"class":82}," con:\n",[35,163,165,168,171,174,177,180],{"class":37,"line":164},9,[35,166,167],{"class":78},"        return",[35,169,170],{"class":82}," con.execute(",[35,172,173],{"class":45},"\"SELECT MAX(updated_at) || '\u002F' || COUNT(*) FROM sales\"",[35,175,176],{"class":82},").fetchone()[",[35,178,179],{"class":110},"0",[35,181,182],{"class":82},"]\n",[35,184,186],{"class":37,"line":185},10,[35,187,104],{"emptyLinePlaceholder":103},[35,189,191,193,196,199,202,205],{"class":37,"line":190},11,[35,192,128],{"class":78},[35,194,195],{"class":131}," cache_key",[35,197,198],{"class":82},"(report, params, version, code_version",[35,200,201],{"class":78},"=",[35,203,204],{"class":45},"\"v3\"",[35,206,207],{"class":82},"):\n",[35,209,211,214,216,219,222,225,228,231,234,237,240,243,247,249,252],{"class":37,"line":210},12,[35,212,213],{"class":82},"    payload ",[35,215,201],{"class":78},[35,217,218],{"class":82}," json.dumps({",[35,220,221],{"class":45},"\"report\"",[35,223,224],{"class":82},": report, ",[35,226,227],{"class":45},"\"params\"",[35,229,230],{"class":82},": params, ",[35,232,233],{"class":45},"\"data\"",[35,235,236],{"class":82},": version, ",[35,238,239],{"class":45},"\"code\"",[35,241,242],{"class":82},": code_version}, ",[35,244,246],{"class":245},"sa561","sort_keys",[35,248,201],{"class":78},[35,250,251],{"class":110},"True",[35,253,254],{"class":82},")\n",[35,256,258,261,264,267],{"class":37,"line":257},13,[35,259,260],{"class":78},"    return",[35,262,263],{"class":82}," hashlib.sha256(payload.encode()).hexdigest()[:",[35,265,266],{"class":110},"32",[35,268,182],{"class":82},[35,270,272],{"class":37,"line":271},14,[35,273,104],{"emptyLinePlaceholder":103},[35,275,277,280,282],{"class":37,"line":276},15,[35,278,279],{"class":82},"v ",[35,281,201],{"class":78},[35,283,284],{"class":82}," data_version()\n",[35,286,288,291,294,297],{"class":37,"line":287},16,[35,289,290],{"class":110},"print",[35,292,293],{"class":82},"(",[35,295,296],{"class":45},"\"data version:\"",[35,298,299],{"class":82},", v)\n",[35,301,303,305,308,311,314,317,320,323,326,329,331,334],{"class":37,"line":302},17,[35,304,290],{"class":110},[35,306,307],{"class":82},"(cache_key(",[35,309,310],{"class":45},"\"sales\"",[35,312,313],{"class":82},", {",[35,315,316],{"class":45},"\"month\"",[35,318,319],{"class":82},": ",[35,321,322],{"class":45},"\"2026-09\"",[35,324,325],{"class":82},", ",[35,327,328],{"class":45},"\"region\"",[35,330,319],{"class":82},[35,332,333],{"class":45},"\"North\"",[35,335,336],{"class":82},"}, v))\n",[35,338,340,342,344,346,348,350,352,354,356,358,360,362,365],{"class":37,"line":339},18,[35,341,290],{"class":110},[35,343,307],{"class":82},[35,345,310],{"class":45},[35,347,313],{"class":82},[35,349,328],{"class":45},[35,351,319],{"class":82},[35,353,333],{"class":45},[35,355,325],{"class":82},[35,357,316],{"class":45},[35,359,319],{"class":82},[35,361,322],{"class":45},[35,363,364],{"class":82},"}, v))     ",[35,366,368],{"class":367},"s-wDw","# same key, order does not matter\n",[10,370,371,372,374,375,378],{},"The cache key decides when a cached file can be reused, so it must include everything that changes the file's content. That is three things: the request parameters (which month, which region), a version of the data, and a version of the code that builds the workbook. The data version here is the latest ",[32,373,61],{}," plus the row count — a cheap query that changes whenever rows are added, changed or deleted, without reading the data itself. The code version is a constant you bump when the layout of the export changes, so a new column or new styling is not hidden behind old cached files. Serialising the parameters with ",[32,376,377],{},"sort_keys=True"," before hashing makes the key independent of the order the parameters arrived in.",[380,381,389,390,389,394,389,398,389,405,389,415,389,422,389,427,389,432,389,437,389,441,389,446,389,450,389,454,389,457,389,462,389,467,389,471,389,475,389,478,389,483,389,488,389,492,389,496,389,499,389,502,389,506],"svg",{"viewBox":382,"role":383,"ariaLabelledBy":384,"xmlns":387,"style":388},"84 0 592 471","img",[385,386],"cache-key-t","cache-key-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:592px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[391,392,393],"title",{"id":385},"What goes into the cache key",[395,396,397],"desc",{"id":386},"The cache key is a hash of the report name, the request parameters with sorted keys, a data version from the latest update time and row count, and a code version that is bumped when the export layout changes.",[399,400],"rect",{"x":401,"y":179,"width":402,"height":403,"fill":404},"84","592","471","#ffffff",[399,406],{"x":407,"y":408,"width":409,"height":410,"rx":411,"fill":412,"stroke":413,"style":414},"100.0","16.0","560.0","59.0","12","#e7ebef","var(--line,#cdd5e6)","stroke-width:2px",[416,417,421],"text",{"x":418,"y":419,"style":420},"380.0","41.5","font-size:13.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Report name",[416,423,426],{"x":418,"y":424,"style":425},"61.0","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","which export",[37,428],{"x1":418,"y1":429,"x2":418,"y2":430,"stroke":431,"style":414},"80.0","102.0","var(--brand,#5b5cf0)",[433,434],"polygon",{"points":435,"fill":436},"380.0,106.0 375.0,97.0 385.0,97.0","#5b5cf0",[399,438],{"x":407,"y":439,"width":409,"height":410,"rx":411,"fill":440,"stroke":431,"style":414},"111.0","#f0f4ff",[416,442,445],{"x":418,"y":443,"style":444},"136.5","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Parameters",[416,447,449],{"x":418,"y":448,"style":425},"156.0","month, region, sorted",[37,451],{"x1":418,"y1":452,"x2":418,"y2":453,"stroke":431,"style":414},"175.0","197.0",[433,455],{"points":456,"fill":436},"380.0,201.0 375.0,192.0 385.0,192.0",[399,458],{"x":407,"y":459,"width":409,"height":410,"rx":411,"fill":460,"stroke":461,"style":414},"206.0","#fdefd8","var(--gold,#b4740a)",[416,463,466],{"x":418,"y":464,"style":465},"231.5","font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Data version",[416,468,470],{"x":418,"y":469,"style":425},"251.0","MAX(updated_at) \u002F COUNT(*)",[37,472],{"x1":418,"y1":473,"x2":418,"y2":474,"stroke":431,"style":414},"270.0","292.0",[433,476],{"points":477,"fill":436},"380.0,296.0 375.0,287.0 385.0,287.0",[399,479],{"x":407,"y":480,"width":409,"height":410,"rx":411,"fill":481,"stroke":482,"style":414},"301.0","#d9f4f1","var(--teal,#0f9488)",[416,484,487],{"x":418,"y":485,"style":486},"326.5","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Code version",[416,489,491],{"x":418,"y":490,"style":425},"346.0","bump when layout changes",[37,493],{"x1":418,"y1":494,"x2":418,"y2":495,"stroke":431,"style":414},"365.0","387.0",[433,497],{"points":498,"fill":436},"380.0,391.0 375.0,382.0 385.0,382.0",[399,500],{"x":407,"y":501,"width":409,"height":410,"rx":411,"fill":412,"stroke":413,"style":414},"396.0",[416,503,505],{"x":418,"y":504,"style":420},"421.5","SHA-256 → key",[416,507,509],{"x":418,"y":508,"style":425},"441.0","file name and ETag",[20,511,513],{"id":512},"step-2-build-once-write-atomically-one-builder-per-key","Step 2 — Build once, write atomically, one builder per key",[25,515,517],{"className":69,"code":516,"language":71,"meta":30,"style":30},"import os, tempfile, threading, time\nfrom pathlib import Path\nimport pandas as pd\n\nCACHE = Path(\"export_cache\")\nCACHE.mkdir(exist_ok=True)\n_locks, _locks_guard = {}, threading.Lock()\nbuilds = []                                     # for the demonstration: which keys were actually built\n\ndef build_workbook(params, target):\n    with closing(sqlite3.connect(DB)) as con:\n        df = pd.read_sql(\"SELECT region, month, revenue FROM sales WHERE month = ? AND region = ?\", con,\n                         params=(params[\"month\"], params[\"region\"]))\n    fd, tmp = tempfile.mkstemp(dir=CACHE, suffix=\".tmp\")\n    with os.fdopen(fd, \"wb\") as f:              # a file object: openpyxl would reject the .tmp name\n        df.to_excel(f, index=False, engine=\"openpyxl\")\n    os.replace(tmp, target)                     # atomic: readers see the old file or the whole new one\n\ndef cached_export(params):\n    key = cache_key(\"sales\", params, data_version())\n    path = CACHE \u002F f\"{key}.xlsx\"\n    if not path.exists():\n        with _locks_guard:\n            lock = _locks.setdefault(key, threading.Lock())\n        with lock:                              # only one request builds a given key\n            if not path.exists():\n                builds.append(key)\n                build_workbook(params, path)\n    os.utime(path)                              # touch: records last use for eviction\n    return key, path\n",[32,518,519,526,538,550,554,569,585,595,608,612,622,636,652,672,701,722,747,755,759,770,786,819,831,840,851,862,872,878,884,893],{"__ignoreMap":30},[35,520,521,523],{"class":37,"line":38},[35,522,79],{"class":78},[35,524,525],{"class":82}," os, tempfile, threading, time\n",[35,527,528,530,533,535],{"class":37,"line":86},[35,529,89],{"class":78},[35,531,532],{"class":82}," pathlib ",[35,534,79],{"class":78},[35,536,537],{"class":82}," Path\n",[35,539,540,542,545,547],{"class":37,"line":100},[35,541,79],{"class":78},[35,543,544],{"class":82}," pandas ",[35,546,158],{"class":78},[35,548,549],{"class":82}," pd\n",[35,551,552],{"class":37,"line":107},[35,553,104],{"emptyLinePlaceholder":103},[35,555,556,559,561,564,567],{"class":37,"line":120},[35,557,558],{"class":110},"CACHE",[35,560,114],{"class":78},[35,562,563],{"class":82}," Path(",[35,565,566],{"class":45},"\"export_cache\"",[35,568,254],{"class":82},[35,570,571,573,576,579,581,583],{"class":37,"line":125},[35,572,558],{"class":110},[35,574,575],{"class":82},".mkdir(",[35,577,578],{"class":245},"exist_ok",[35,580,201],{"class":78},[35,582,251],{"class":110},[35,584,254],{"class":82},[35,586,587,590,592],{"class":37,"line":138},[35,588,589],{"class":82},"_locks, _locks_guard ",[35,591,201],{"class":78},[35,593,594],{"class":82}," {}, threading.Lock()\n",[35,596,597,600,602,605],{"class":37,"line":144},[35,598,599],{"class":82},"builds ",[35,601,201],{"class":78},[35,603,604],{"class":82}," []                                     ",[35,606,607],{"class":367},"# for the demonstration: which keys were actually built\n",[35,609,610],{"class":37,"line":164},[35,611,104],{"emptyLinePlaceholder":103},[35,613,614,616,619],{"class":37,"line":185},[35,615,128],{"class":78},[35,617,618],{"class":131}," build_workbook",[35,620,621],{"class":82},"(params, target):\n",[35,623,624,626,628,630,632,634],{"class":37,"line":190},[35,625,147],{"class":78},[35,627,150],{"class":82},[35,629,111],{"class":110},[35,631,155],{"class":82},[35,633,158],{"class":78},[35,635,161],{"class":82},[35,637,638,641,643,646,649],{"class":37,"line":210},[35,639,640],{"class":82},"        df ",[35,642,201],{"class":78},[35,644,645],{"class":82}," pd.read_sql(",[35,647,648],{"class":45},"\"SELECT region, month, revenue FROM sales WHERE month = ? AND region = ?\"",[35,650,651],{"class":82},", con,\n",[35,653,654,657,659,662,664,667,669],{"class":37,"line":257},[35,655,656],{"class":245},"                         params",[35,658,201],{"class":78},[35,660,661],{"class":82},"(params[",[35,663,316],{"class":45},[35,665,666],{"class":82},"], params[",[35,668,328],{"class":45},[35,670,671],{"class":82},"]))\n",[35,673,674,677,679,682,685,687,689,691,694,696,699],{"class":37,"line":271},[35,675,676],{"class":82},"    fd, tmp ",[35,678,201],{"class":78},[35,680,681],{"class":82}," tempfile.mkstemp(",[35,683,684],{"class":245},"dir",[35,686,201],{"class":78},[35,688,558],{"class":110},[35,690,325],{"class":82},[35,692,693],{"class":245},"suffix",[35,695,201],{"class":78},[35,697,698],{"class":45},"\".tmp\"",[35,700,254],{"class":82},[35,702,703,705,708,711,714,716,719],{"class":37,"line":276},[35,704,147],{"class":78},[35,706,707],{"class":82}," os.fdopen(fd, ",[35,709,710],{"class":45},"\"wb\"",[35,712,713],{"class":82},") ",[35,715,158],{"class":78},[35,717,718],{"class":82}," f:              ",[35,720,721],{"class":367},"# a file object: openpyxl would reject the .tmp name\n",[35,723,724,727,730,732,735,737,740,742,745],{"class":37,"line":287},[35,725,726],{"class":82},"        df.to_excel(f, ",[35,728,729],{"class":245},"index",[35,731,201],{"class":78},[35,733,734],{"class":110},"False",[35,736,325],{"class":82},[35,738,739],{"class":245},"engine",[35,741,201],{"class":78},[35,743,744],{"class":45},"\"openpyxl\"",[35,746,254],{"class":82},[35,748,749,752],{"class":37,"line":302},[35,750,751],{"class":82},"    os.replace(tmp, target)                     ",[35,753,754],{"class":367},"# atomic: readers see the old file or the whole new one\n",[35,756,757],{"class":37,"line":339},[35,758,104],{"emptyLinePlaceholder":103},[35,760,762,764,767],{"class":37,"line":761},19,[35,763,128],{"class":78},[35,765,766],{"class":131}," cached_export",[35,768,769],{"class":82},"(params):\n",[35,771,773,776,778,781,783],{"class":37,"line":772},20,[35,774,775],{"class":82},"    key ",[35,777,201],{"class":78},[35,779,780],{"class":82}," cache_key(",[35,782,310],{"class":45},[35,784,785],{"class":82},", params, data_version())\n",[35,787,789,792,794,797,800,803,806,810,813,816],{"class":37,"line":788},21,[35,790,791],{"class":82},"    path ",[35,793,201],{"class":78},[35,795,796],{"class":110}," CACHE",[35,798,799],{"class":78}," \u002F",[35,801,802],{"class":78}," f",[35,804,805],{"class":45},"\"",[35,807,809],{"class":808},"sSjpA","{",[35,811,812],{"class":82},"key",[35,814,815],{"class":808},"}",[35,817,818],{"class":45},".xlsx\"\n",[35,820,822,825,828],{"class":37,"line":821},22,[35,823,824],{"class":78},"    if",[35,826,827],{"class":78}," not",[35,829,830],{"class":82}," path.exists():\n",[35,832,834,837],{"class":37,"line":833},23,[35,835,836],{"class":78},"        with",[35,838,839],{"class":82}," _locks_guard:\n",[35,841,843,846,848],{"class":37,"line":842},24,[35,844,845],{"class":82},"            lock ",[35,847,201],{"class":78},[35,849,850],{"class":82}," _locks.setdefault(key, threading.Lock())\n",[35,852,854,856,859],{"class":37,"line":853},25,[35,855,836],{"class":78},[35,857,858],{"class":82}," lock:                              ",[35,860,861],{"class":367},"# only one request builds a given key\n",[35,863,865,868,870],{"class":37,"line":864},26,[35,866,867],{"class":78},"            if",[35,869,827],{"class":78},[35,871,830],{"class":82},[35,873,875],{"class":37,"line":874},27,[35,876,877],{"class":82},"                builds.append(key)\n",[35,879,881],{"class":37,"line":880},28,[35,882,883],{"class":82},"                build_workbook(params, path)\n",[35,885,887,890],{"class":37,"line":886},29,[35,888,889],{"class":82},"    os.utime(path)                              ",[35,891,892],{"class":367},"# touch: records last use for eviction\n",[35,894,896,898],{"class":37,"line":895},30,[35,897,260],{"class":78},[35,899,900],{"class":82}," key, path\n",[10,902,903,904,907,908,18],{},"Two requests for the same uncached export can arrive within the same second. Without coordination both would build it, doubling the work at exactly the moment the server is busiest. A lock per cache key makes the second request wait for the first and then find the finished file — the second ",[32,905,906],{},"path.exists()"," check, inside the lock, is what makes that work. The lock is per key, so exports for different months still build in parallel. With several worker processes, a per-key file lock replaces the thread lock, as in ",[14,909,911],{"href":910},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fprevent-overlapping-runs-of-an-excel-report-job\u002F","Prevent Overlapping Runs of an Excel Report Job",[10,913,914,915,918,919,922],{},"The workbook is written to a temporary file in the cache folder and then renamed into place with ",[32,916,917],{},"os.replace",", which is atomic on the same file system. A request that arrives while a build is in progress therefore never finds a half-written file under the final name, and a build that crashes leaves only a stray ",[32,920,921],{},".tmp"," file behind, never a corrupt cache entry. Touching the file on every use records when it was last needed, which the eviction step relies on.",[20,924,926],{"id":925},"step-3-the-endpoint-with-etag-and-304","Step 3 — The endpoint, with ETag and 304",[25,928,930],{"className":69,"code":929,"language":71,"meta":30,"style":30},"from flask import Flask, request, send_file\n\napp = Flask(__name__)\n\n@app.get(\"\u002Fexports\u002Fsales.xlsx\")\ndef sales_export():\n    params = {\"month\": request.args[\"month\"], \"region\": request.args.get(\"region\", \"North\")}\n    key, path = cached_export(params)\n    if request.if_none_match.contains(key):\n        return \"\", 304, {\"ETag\": f'\"{key}\"', \"Cache-Control\": \"private, max-age=0, must-revalidate\"}\n    response = send_file(path, as_attachment=True, download_name=f\"sales_{params['month']}_{params['region']}.xlsx\",\n                         mimetype=\"application\u002Fvnd.openxmlformats-officedocument.spreadsheetml.sheet\", etag=False)\n    response.set_etag(key)\n    response.headers[\"Cache-Control\"] = \"private, max-age=0, must-revalidate\"\n    return response\n\nclient = app.test_client()\nt0 = time.perf_counter(); r1 = client.get(\"\u002Fexports\u002Fsales.xlsx?month=2026-09&region=North\"); t1 = time.perf_counter() - t0\nt0 = time.perf_counter(); r2 = client.get(\"\u002Fexports\u002Fsales.xlsx?month=2026-09&region=North\"); t2 = time.perf_counter() - t0\nr3 = client.get(\"\u002Fexports\u002Fsales.xlsx?month=2026-09&region=North\", headers={\"If-None-Match\": r1.headers[\"ETag\"]})\nprint(r1.status_code, f\"{len(r1.data):,} bytes in {t1 * 1000:.0f} ms | repeat {r2.status_code} in {t2 * 1000:.0f} ms | \"\n      f\"conditional {r3.status_code}, {len(r3.data)} bytes | builds: {len(builds)}\")\nfor r in (r1, r2, r3):\n    r.close()                                   # releases the file handle send_file opened\n",[32,931,932,944,948,963,967,979,988,1022,1032,1039,1086,1148,1169,1174,1189,1196,1200,1210,1242,1268,1299,1371,1413,1427],{"__ignoreMap":30},[35,933,934,936,939,941],{"class":37,"line":38},[35,935,89],{"class":78},[35,937,938],{"class":82}," flask ",[35,940,79],{"class":78},[35,942,943],{"class":82}," Flask, request, send_file\n",[35,945,946],{"class":37,"line":86},[35,947,104],{"emptyLinePlaceholder":103},[35,949,950,953,955,958,961],{"class":37,"line":100},[35,951,952],{"class":82},"app ",[35,954,201],{"class":78},[35,956,957],{"class":82}," Flask(",[35,959,960],{"class":110},"__name__",[35,962,254],{"class":82},[35,964,965],{"class":37,"line":107},[35,966,104],{"emptyLinePlaceholder":103},[35,968,969,972,974,977],{"class":37,"line":120},[35,970,971],{"class":131},"@app.get",[35,973,293],{"class":82},[35,975,976],{"class":45},"\"\u002Fexports\u002Fsales.xlsx\"",[35,978,254],{"class":82},[35,980,981,983,986],{"class":37,"line":125},[35,982,128],{"class":78},[35,984,985],{"class":131}," sales_export",[35,987,135],{"class":82},[35,989,990,993,995,998,1000,1003,1005,1008,1010,1013,1015,1017,1019],{"class":37,"line":138},[35,991,992],{"class":82},"    params ",[35,994,201],{"class":78},[35,996,997],{"class":82}," {",[35,999,316],{"class":45},[35,1001,1002],{"class":82},": request.args[",[35,1004,316],{"class":45},[35,1006,1007],{"class":82},"], ",[35,1009,328],{"class":45},[35,1011,1012],{"class":82},": request.args.get(",[35,1014,328],{"class":45},[35,1016,325],{"class":82},[35,1018,333],{"class":45},[35,1020,1021],{"class":82},")}\n",[35,1023,1024,1027,1029],{"class":37,"line":144},[35,1025,1026],{"class":82},"    key, path ",[35,1028,201],{"class":78},[35,1030,1031],{"class":82}," cached_export(params)\n",[35,1033,1034,1036],{"class":37,"line":164},[35,1035,824],{"class":78},[35,1037,1038],{"class":82}," request.if_none_match.contains(key):\n",[35,1040,1041,1043,1046,1048,1051,1053,1056,1058,1061,1064,1066,1068,1070,1073,1075,1078,1080,1083],{"class":37,"line":185},[35,1042,167],{"class":78},[35,1044,1045],{"class":45}," \"\"",[35,1047,325],{"class":82},[35,1049,1050],{"class":110},"304",[35,1052,313],{"class":82},[35,1054,1055],{"class":45},"\"ETag\"",[35,1057,319],{"class":82},[35,1059,1060],{"class":78},"f",[35,1062,1063],{"class":45},"'\"",[35,1065,809],{"class":808},[35,1067,812],{"class":82},[35,1069,815],{"class":808},[35,1071,1072],{"class":45},"\"'",[35,1074,325],{"class":82},[35,1076,1077],{"class":45},"\"Cache-Control\"",[35,1079,319],{"class":82},[35,1081,1082],{"class":45},"\"private, max-age=0, must-revalidate\"",[35,1084,1085],{"class":82},"}\n",[35,1087,1088,1091,1093,1096,1099,1101,1103,1105,1108,1110,1112,1115,1117,1120,1123,1126,1128,1131,1133,1135,1138,1140,1142,1145],{"class":37,"line":190},[35,1089,1090],{"class":82},"    response ",[35,1092,201],{"class":78},[35,1094,1095],{"class":82}," send_file(path, ",[35,1097,1098],{"class":245},"as_attachment",[35,1100,201],{"class":78},[35,1102,251],{"class":110},[35,1104,325],{"class":82},[35,1106,1107],{"class":245},"download_name",[35,1109,201],{"class":78},[35,1111,1060],{"class":78},[35,1113,1114],{"class":45},"\"sales_",[35,1116,809],{"class":808},[35,1118,1119],{"class":82},"params[",[35,1121,1122],{"class":45},"'month'",[35,1124,1125],{"class":82},"]",[35,1127,815],{"class":808},[35,1129,1130],{"class":45},"_",[35,1132,809],{"class":808},[35,1134,1119],{"class":82},[35,1136,1137],{"class":45},"'region'",[35,1139,1125],{"class":82},[35,1141,815],{"class":808},[35,1143,1144],{"class":45},".xlsx\"",[35,1146,1147],{"class":82},",\n",[35,1149,1150,1153,1155,1158,1160,1163,1165,1167],{"class":37,"line":210},[35,1151,1152],{"class":245},"                         mimetype",[35,1154,201],{"class":78},[35,1156,1157],{"class":45},"\"application\u002Fvnd.openxmlformats-officedocument.spreadsheetml.sheet\"",[35,1159,325],{"class":82},[35,1161,1162],{"class":245},"etag",[35,1164,201],{"class":78},[35,1166,734],{"class":110},[35,1168,254],{"class":82},[35,1170,1171],{"class":37,"line":257},[35,1172,1173],{"class":82},"    response.set_etag(key)\n",[35,1175,1176,1179,1181,1184,1186],{"class":37,"line":271},[35,1177,1178],{"class":82},"    response.headers[",[35,1180,1077],{"class":45},[35,1182,1183],{"class":82},"] ",[35,1185,201],{"class":78},[35,1187,1188],{"class":45}," \"private, max-age=0, must-revalidate\"\n",[35,1190,1191,1193],{"class":37,"line":276},[35,1192,260],{"class":78},[35,1194,1195],{"class":82}," response\n",[35,1197,1198],{"class":37,"line":287},[35,1199,104],{"emptyLinePlaceholder":103},[35,1201,1202,1205,1207],{"class":37,"line":302},[35,1203,1204],{"class":82},"client ",[35,1206,201],{"class":78},[35,1208,1209],{"class":82}," app.test_client()\n",[35,1211,1212,1215,1217,1220,1222,1225,1228,1231,1233,1236,1239],{"class":37,"line":339},[35,1213,1214],{"class":82},"t0 ",[35,1216,201],{"class":78},[35,1218,1219],{"class":82}," time.perf_counter(); r1 ",[35,1221,201],{"class":78},[35,1223,1224],{"class":82}," client.get(",[35,1226,1227],{"class":45},"\"\u002Fexports\u002Fsales.xlsx?month=2026-09&region=North\"",[35,1229,1230],{"class":82},"); t1 ",[35,1232,201],{"class":78},[35,1234,1235],{"class":82}," time.perf_counter() ",[35,1237,1238],{"class":78},"-",[35,1240,1241],{"class":82}," t0\n",[35,1243,1244,1246,1248,1251,1253,1255,1257,1260,1262,1264,1266],{"class":37,"line":761},[35,1245,1214],{"class":82},[35,1247,201],{"class":78},[35,1249,1250],{"class":82}," time.perf_counter(); r2 ",[35,1252,201],{"class":78},[35,1254,1224],{"class":82},[35,1256,1227],{"class":45},[35,1258,1259],{"class":82},"); t2 ",[35,1261,201],{"class":78},[35,1263,1235],{"class":82},[35,1265,1238],{"class":78},[35,1267,1241],{"class":82},[35,1269,1270,1273,1275,1277,1279,1281,1284,1286,1288,1291,1294,1296],{"class":37,"line":772},[35,1271,1272],{"class":82},"r3 ",[35,1274,201],{"class":78},[35,1276,1224],{"class":82},[35,1278,1227],{"class":45},[35,1280,325],{"class":82},[35,1282,1283],{"class":245},"headers",[35,1285,201],{"class":78},[35,1287,809],{"class":82},[35,1289,1290],{"class":45},"\"If-None-Match\"",[35,1292,1293],{"class":82},": r1.headers[",[35,1295,1055],{"class":45},[35,1297,1298],{"class":82},"]})\n",[35,1300,1301,1303,1306,1308,1310,1312,1315,1318,1321,1323,1326,1328,1331,1334,1337,1340,1342,1345,1347,1350,1352,1355,1357,1360,1362,1364,1366,1368],{"class":37,"line":788},[35,1302,290],{"class":110},[35,1304,1305],{"class":82},"(r1.status_code, ",[35,1307,1060],{"class":78},[35,1309,805],{"class":45},[35,1311,809],{"class":808},[35,1313,1314],{"class":110},"len",[35,1316,1317],{"class":82},"(r1.data)",[35,1319,1320],{"class":78},":,",[35,1322,815],{"class":808},[35,1324,1325],{"class":45}," bytes in ",[35,1327,809],{"class":808},[35,1329,1330],{"class":82},"t1 ",[35,1332,1333],{"class":78},"*",[35,1335,1336],{"class":110}," 1000",[35,1338,1339],{"class":78},":.0f",[35,1341,815],{"class":808},[35,1343,1344],{"class":45}," ms | repeat ",[35,1346,809],{"class":808},[35,1348,1349],{"class":82},"r2.status_code",[35,1351,815],{"class":808},[35,1353,1354],{"class":45}," in ",[35,1356,809],{"class":808},[35,1358,1359],{"class":82},"t2 ",[35,1361,1333],{"class":78},[35,1363,1336],{"class":110},[35,1365,1339],{"class":78},[35,1367,815],{"class":808},[35,1369,1370],{"class":45}," ms | \"\n",[35,1372,1373,1376,1379,1381,1384,1386,1388,1390,1392,1395,1397,1400,1402,1404,1407,1409,1411],{"class":37,"line":821},[35,1374,1375],{"class":78},"      f",[35,1377,1378],{"class":45},"\"conditional ",[35,1380,809],{"class":808},[35,1382,1383],{"class":82},"r3.status_code",[35,1385,815],{"class":808},[35,1387,325],{"class":45},[35,1389,809],{"class":808},[35,1391,1314],{"class":110},[35,1393,1394],{"class":82},"(r3.data)",[35,1396,815],{"class":808},[35,1398,1399],{"class":45}," bytes | builds: ",[35,1401,809],{"class":808},[35,1403,1314],{"class":110},[35,1405,1406],{"class":82},"(builds)",[35,1408,815],{"class":808},[35,1410,805],{"class":45},[35,1412,254],{"class":82},[35,1414,1415,1418,1421,1424],{"class":37,"line":833},[35,1416,1417],{"class":78},"for",[35,1419,1420],{"class":82}," r ",[35,1422,1423],{"class":78},"in",[35,1425,1426],{"class":82}," (r1, r2, r3):\n",[35,1428,1429,1432],{"class":37,"line":842},[35,1430,1431],{"class":82},"    r.close()                                   ",[35,1433,1434],{"class":367},"# releases the file handle send_file opened\n",[10,1436,1437,1438,1441,1442,1445,1446,1449,1450,1453,1454,1457,1458,1461],{},"The cache key doubles as the HTTP ",[32,1439,1440],{},"ETag",": a short identifier for this exact version of the file. The browser stores it with the download, and when it asks again it can send ",[32,1443,1444],{},"If-None-Match"," with that value; if the key is unchanged, the endpoint replies ",[32,1447,1448],{},"304 Not Modified"," with no body, and the browser uses its copy. The key is used rather than a hash of the file's bytes on purpose: openpyxl writes the creation time into every workbook, so two builds of identical data produce different bytes, and an ETag based on bytes would change on every rebuild. ",[32,1451,1452],{},"private"," keeps shared proxies from storing a file that may contain one user's data; ",[32,1455,1456],{},"must-revalidate"," with ",[32,1459,1460],{},"max-age=0"," tells the browser to check each time, which costs a tiny request and guarantees nobody keeps a stale export.",[10,1463,1464],{},"The test client shows the three cases: the first request builds the file, the repeat is served from disk much faster, and the conditional request gets a 304 with an empty body — and the workbook was built only once.",[380,1466,389,1472,389,1475,389,1478,389,1483,389,1489,389,1493,389,1499,389,1503,389,1507,389,1511,389,1515,389,1519,389,1523,389,1527,389,1531,389,1535,389,1539,389,1542,389,1546,389,1548,389,1551,389,1553,389,1556,389,1558,389,1561,389,1563,389,1566,389,1569,389,1573,389,1575,389,1579,389,1581,389,1584,389,1586,389,1589,389,1591,389,1594,389,1596,389,1599,389,1602,389,1606,389,1608,389,1611,389,1613,389,1615,389,1617,389,1619,389,1621,389,1624,389,1626,389,1630,389,1633,389,1637,389,1639,389,1642,389,1644,389,1646,389,1648,389,1651,389,1653,389,1656,389,1658],{"viewBox":1467,"role":383,"ariaLabelledBy":1468,"xmlns":387,"style":1471},"123 0 514 168",[1469,1470],"cache-results-t","cache-results-d","width:100%;max-width:514px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[391,1473,1474],{"id":1469},"Three requests for the same export",[395,1476,1477],{"id":1470},"The first request builds and sends the 13 kilobyte workbook in about 70 milliseconds. The repeat is served from the cache in about 2 milliseconds. A request that sends the ETag back gets 304 Not Modified with no body. Only one build happened.",[399,1479],{"x":1480,"y":179,"width":1481,"height":1482,"fill":404},"123","514","168",[399,1484],{"x":1485,"y":408,"width":1486,"height":1487,"rx":179,"fill":412,"stroke":413,"style":1488},"139.3","32.0","24.0","stroke-width:1px",[399,1490],{"x":1491,"y":408,"width":1492,"height":1487,"rx":179,"fill":412,"stroke":413,"style":1488},"171.3","147.9",[416,1494,1498],{"x":1495,"y":1496,"style":1497},"245.3","33.0","font-size:11px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle","A",[399,1500],{"x":1501,"y":408,"width":1502,"height":1487,"rx":179,"fill":412,"stroke":413,"style":1488},"319.2","63.7",[416,1504,1506],{"x":1505,"y":1496,"style":1497},"351.1","B",[399,1508],{"x":1509,"y":408,"width":1510,"height":1487,"rx":179,"fill":412,"stroke":413,"style":1488},"382.9","70.2",[416,1512,1514],{"x":1513,"y":1496,"style":1497},"418.0","C",[399,1516],{"x":1517,"y":408,"width":1518,"height":1487,"rx":179,"fill":412,"stroke":413,"style":1488},"453.1","71.9",[416,1520,1522],{"x":1521,"y":1496,"style":1497},"489.1","D",[399,1524],{"x":1525,"y":408,"width":1526,"height":1487,"rx":179,"fill":412,"stroke":413,"style":1488},"525.0","95.6",[416,1528,1530],{"x":1529,"y":1496,"style":1497},"572.8","E",[399,1532],{"x":1485,"y":1533,"width":1486,"height":1534,"rx":179,"fill":412,"stroke":413,"style":1488},"40.0","28.0",[416,1536,1538],{"x":1537,"y":410,"style":1497},"155.3","1",[399,1540],{"x":1491,"y":1533,"width":1492,"height":1534,"rx":179,"fill":440,"stroke":431,"style":1541},"stroke-width:1.5px",[416,1543,1545],{"x":1495,"y":410,"style":1544},"font-size:12px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Request",[399,1547],{"x":1501,"y":1533,"width":1502,"height":1534,"rx":179,"fill":440,"stroke":431,"style":1541},[416,1549,1550],{"x":1505,"y":410,"style":1544},"Status",[399,1552],{"x":1509,"y":1533,"width":1510,"height":1534,"rx":179,"fill":440,"stroke":431,"style":1541},[416,1554,1555],{"x":1513,"y":410,"style":1544},"Body",[399,1557],{"x":1517,"y":1533,"width":1518,"height":1534,"rx":179,"fill":440,"stroke":431,"style":1541},[416,1559,1560],{"x":1521,"y":410,"style":1544},"Time",[399,1562],{"x":1525,"y":1533,"width":1526,"height":1534,"rx":179,"fill":440,"stroke":431,"style":1541},[416,1564,1565],{"x":1529,"y":410,"style":1544},"Built?",[399,1567],{"x":1485,"y":1568,"width":1486,"height":1534,"rx":179,"fill":412,"stroke":413,"style":1488},"68.0",[416,1570,1572],{"x":1537,"y":1571,"style":1497},"87.0","2",[399,1574],{"x":1491,"y":1568,"width":1492,"height":1534,"rx":179,"fill":404,"stroke":413,"style":1488},[416,1576,1578],{"x":1495,"y":1571,"style":1577},"font-size:12px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","First download",[399,1580],{"x":1501,"y":1568,"width":1502,"height":1534,"rx":179,"fill":404,"stroke":413,"style":1488},[416,1582,1583],{"x":1505,"y":1571,"style":1577},"200",[399,1585],{"x":1509,"y":1568,"width":1510,"height":1534,"rx":179,"fill":404,"stroke":413,"style":1488},[416,1587,1588],{"x":1513,"y":1571,"style":1577},"≈ 13 KB",[399,1590],{"x":1517,"y":1568,"width":1518,"height":1534,"rx":179,"fill":404,"stroke":413,"style":1488},[416,1592,1593],{"x":1521,"y":1571,"style":1577},"≈ 70 ms",[399,1595],{"x":1525,"y":1568,"width":1526,"height":1534,"rx":179,"fill":404,"stroke":413,"style":1488},[416,1597,1598],{"x":1529,"y":1571,"style":1577},"yes",[399,1600],{"x":1485,"y":1601,"width":1486,"height":1534,"rx":179,"fill":412,"stroke":413,"style":1488},"96.0",[416,1603,1605],{"x":1537,"y":1604,"style":1497},"115.0","3",[399,1607],{"x":1491,"y":1601,"width":1492,"height":1534,"rx":179,"fill":404,"stroke":413,"style":1488},[416,1609,1610],{"x":1495,"y":1604,"style":1577},"Repeat download",[399,1612],{"x":1501,"y":1601,"width":1502,"height":1534,"rx":179,"fill":404,"stroke":413,"style":1488},[416,1614,1583],{"x":1505,"y":1604,"style":1577},[399,1616],{"x":1509,"y":1601,"width":1510,"height":1534,"rx":179,"fill":404,"stroke":413,"style":1488},[416,1618,1588],{"x":1513,"y":1604,"style":1577},[399,1620],{"x":1517,"y":1601,"width":1518,"height":1534,"rx":179,"fill":404,"stroke":413,"style":1488},[416,1622,1623],{"x":1521,"y":1604,"style":1577},"≈ 2 ms",[399,1625],{"x":1525,"y":1601,"width":1526,"height":1534,"rx":179,"fill":481,"stroke":482,"style":1541},[416,1627,1629],{"x":1529,"y":1604,"style":1628},"font-size:12px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","no — cache",[399,1631],{"x":1485,"y":1632,"width":1486,"height":1534,"rx":179,"fill":412,"stroke":413,"style":1488},"124.0",[416,1634,1636],{"x":1537,"y":1635,"style":1497},"143.0","4",[399,1638],{"x":1491,"y":1632,"width":1492,"height":1534,"rx":179,"fill":404,"stroke":413,"style":1488},[416,1640,1641],{"x":1495,"y":1635,"style":1577},"With If-None-Match",[399,1643],{"x":1501,"y":1632,"width":1502,"height":1534,"rx":179,"fill":481,"stroke":482,"style":1541},[416,1645,1050],{"x":1505,"y":1635,"style":1628},[399,1647],{"x":1509,"y":1632,"width":1510,"height":1534,"rx":179,"fill":404,"stroke":413,"style":1488},[416,1649,1650],{"x":1513,"y":1635,"style":1577},"0 bytes",[399,1652],{"x":1517,"y":1632,"width":1518,"height":1534,"rx":179,"fill":404,"stroke":413,"style":1488},[416,1654,1655],{"x":1521,"y":1635,"style":1577},"—",[399,1657],{"x":1525,"y":1632,"width":1526,"height":1534,"rx":179,"fill":404,"stroke":413,"style":1488},[416,1659,1660],{"x":1529,"y":1635,"style":1577},"no",[10,1662,1663],{},"The timings are from a small test export; the saving grows with the size and complexity of the workbook, since the cached path only reads a file from disk whatever went into building it.",[380,1665,389,1671,389,1674,389,1677,389,1681,389,1686,389,1689,389,1692,389,1696,389,1701,389,1704,389,1707,389,1711,389,1714,389,1717,389,1721,389,1724,389,1727,389,1729,389,1732,389,1735,389,1739,389,1742,389,1745,389,1749,389,1752,389,1755,389,1759,389,1762,389,1765,389,1770],{"viewBox":1666,"role":383,"ariaLabelledBy":1667,"xmlns":387,"style":1670},"4 0 752 108",[1668,1669],"cache-req-t","cache-req-d","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[391,1672,1673],{"id":1668},"One download request",[395,1675,1676],{"id":1669},"The request's parameters and the current data version give a cache key. If the browser already has that version it gets 304 Not Modified. Otherwise the cached file is sent, after being built once under a per-key lock if it did not exist.",[399,1678],{"x":1636,"y":179,"width":1679,"height":1680,"fill":404},"752","108",[399,1682],{"x":1683,"y":408,"width":1684,"height":1685,"rx":411,"fill":412,"stroke":413,"style":414},"20.0","115.2","76.5",[416,1687,1545],{"x":1688,"y":419,"style":420},"77.6",[416,1690,1691],{"x":1688,"y":424,"style":425},"month and",[416,1693,1695],{"x":1688,"y":1694,"style":425},"78.5","region",[37,1697],{"x1":1698,"y1":1699,"x2":1700,"y2":1699,"stroke":431,"style":414},"140.2","54.2","162.2",[433,1702],{"points":1703,"fill":436},"166.2,54.2 157.2,59.2 157.2,49.2",[399,1705],{"x":1706,"y":408,"width":1684,"height":1685,"rx":411,"fill":440,"stroke":431,"style":414},"171.2",[416,1708,1710],{"x":1709,"y":419,"style":444},"228.8","Cache key",[416,1712,1713],{"x":1709,"y":424,"style":425},"params + data",[416,1715,1716],{"x":1709,"y":1694,"style":425},"version",[37,1718],{"x1":1719,"y1":1699,"x2":1720,"y2":1699,"stroke":431,"style":414},"291.4","313.4",[433,1722],{"points":1723,"fill":436},"317.4,54.2 308.4,59.2 308.4,49.2",[399,1725],{"x":1726,"y":408,"width":1684,"height":1685,"rx":411,"fill":481,"stroke":482,"style":414},"322.4",[416,1728,1444],{"x":418,"y":419,"style":486},[416,1730,1731],{"x":418,"y":410,"style":486},"= key?",[416,1733,1734],{"x":418,"y":1694,"style":425},"304, no body",[37,1736],{"x1":1737,"y1":1699,"x2":1738,"y2":1699,"stroke":431,"style":414},"442.6","464.6",[433,1740],{"points":1741,"fill":436},"468.6,54.2 459.6,59.2 459.6,49.2",[399,1743],{"x":1744,"y":408,"width":1684,"height":1685,"rx":411,"fill":460,"stroke":461,"style":414},"473.6",[416,1746,1748],{"x":1747,"y":419,"style":465},"531.2","File cached?",[416,1750,1751],{"x":1747,"y":424,"style":425},"build once",[416,1753,1754],{"x":1747,"y":1694,"style":425},"under lock",[37,1756],{"x1":1757,"y1":1699,"x2":1758,"y2":1699,"stroke":431,"style":414},"593.8","615.8",[433,1760],{"points":1761,"fill":436},"619.8,54.2 610.8,59.2 610.8,49.2",[399,1763],{"x":1764,"y":408,"width":1684,"height":1685,"rx":411,"fill":412,"stroke":413,"style":414},"624.8",[416,1766,1769],{"x":1767,"y":1768,"style":420},"682.4","50.2","send_file",[416,1771,1773],{"x":1767,"y":1772,"style":425},"69.8","with ETag",[20,1775,1777],{"id":1776},"step-4-notice-data-changes-and-evict-old-files","Step 4 — Notice data changes, and evict old files",[25,1779,1781],{"className":69,"code":1780,"language":71,"meta":30,"style":30},"with closing(sqlite3.connect(DB)) as con, con:\n    con.execute(\"UPDATE sales SET revenue = revenue + 1, updated_at = '2026-10-02T08:15:00' WHERE rowid = 1\")\nr4 = client.get(\"\u002Fexports\u002Fsales.xlsx?month=2026-09&region=North\", headers={\"If-None-Match\": r1.headers[\"ETag\"]})\nprint(\"after a data change:\", r4.status_code, \"| new ETag differs:\", r4.headers[\"ETag\"] != r1.headers[\"ETag\"], \"| builds:\", len(builds))\nr4.close()\n\ndef evict(max_age_s=7 * 86400, max_total_bytes=500 * 1024**2):\n    files = sorted(CACHE.glob(\"*.xlsx\"), key=lambda p: p.stat().st_mtime)      # least recently used first\n    now, total = time.time(), sum(p.stat().st_size for p in files)\n    removed = 0\n    for p in files:\n        if now - p.stat().st_mtime > max_age_s or total > max_total_bytes:\n            total -= p.stat().st_size\n            p.unlink(missing_ok=True)\n            removed += 1\n    for tmp in CACHE.glob(\"*.tmp\"):\n        if now - tmp.stat().st_mtime > 3600:\n            tmp.unlink(missing_ok=True)\n    return removed\n\nprint(\"evicted:\", evict(max_total_bytes=1))\n",[32,1782,1783,1799,1809,1836,1878,1883,1887,1928,1962,1988,1998,2010,2040,2051,2065,2076,2094,2113,2126,2133,2137],{"__ignoreMap":30},[35,1784,1785,1788,1790,1792,1794,1796],{"class":37,"line":38},[35,1786,1787],{"class":78},"with",[35,1789,150],{"class":82},[35,1791,111],{"class":110},[35,1793,155],{"class":82},[35,1795,158],{"class":78},[35,1797,1798],{"class":82}," con, con:\n",[35,1800,1801,1804,1807],{"class":37,"line":86},[35,1802,1803],{"class":82},"    con.execute(",[35,1805,1806],{"class":45},"\"UPDATE sales SET revenue = revenue + 1, updated_at = '2026-10-02T08:15:00' WHERE rowid = 1\"",[35,1808,254],{"class":82},[35,1810,1811,1814,1816,1818,1820,1822,1824,1826,1828,1830,1832,1834],{"class":37,"line":100},[35,1812,1813],{"class":82},"r4 ",[35,1815,201],{"class":78},[35,1817,1224],{"class":82},[35,1819,1227],{"class":45},[35,1821,325],{"class":82},[35,1823,1283],{"class":245},[35,1825,201],{"class":78},[35,1827,809],{"class":82},[35,1829,1290],{"class":45},[35,1831,1293],{"class":82},[35,1833,1055],{"class":45},[35,1835,1298],{"class":82},[35,1837,1838,1840,1842,1845,1848,1851,1854,1856,1858,1861,1864,1866,1868,1871,1873,1875],{"class":37,"line":107},[35,1839,290],{"class":110},[35,1841,293],{"class":82},[35,1843,1844],{"class":45},"\"after a data change:\"",[35,1846,1847],{"class":82},", r4.status_code, ",[35,1849,1850],{"class":45},"\"| new ETag differs:\"",[35,1852,1853],{"class":82},", r4.headers[",[35,1855,1055],{"class":45},[35,1857,1183],{"class":82},[35,1859,1860],{"class":78},"!=",[35,1862,1863],{"class":82}," r1.headers[",[35,1865,1055],{"class":45},[35,1867,1007],{"class":82},[35,1869,1870],{"class":45},"\"| builds:\"",[35,1872,325],{"class":82},[35,1874,1314],{"class":110},[35,1876,1877],{"class":82},"(builds))\n",[35,1879,1880],{"class":37,"line":120},[35,1881,1882],{"class":82},"r4.close()\n",[35,1884,1885],{"class":37,"line":125},[35,1886,104],{"emptyLinePlaceholder":103},[35,1888,1889,1891,1894,1897,1899,1902,1905,1908,1911,1913,1916,1918,1921,1924,1926],{"class":37,"line":138},[35,1890,128],{"class":78},[35,1892,1893],{"class":131}," evict",[35,1895,1896],{"class":82},"(max_age_s",[35,1898,201],{"class":78},[35,1900,1901],{"class":110},"7",[35,1903,1904],{"class":78}," *",[35,1906,1907],{"class":110}," 86400",[35,1909,1910],{"class":82},", max_total_bytes",[35,1912,201],{"class":78},[35,1914,1915],{"class":110},"500",[35,1917,1904],{"class":78},[35,1919,1920],{"class":110}," 1024",[35,1922,1923],{"class":78},"**",[35,1925,1572],{"class":110},[35,1927,207],{"class":82},[35,1929,1930,1933,1935,1938,1940,1942,1945,1948,1951,1953,1956,1959],{"class":37,"line":144},[35,1931,1932],{"class":82},"    files ",[35,1934,201],{"class":78},[35,1936,1937],{"class":110}," sorted",[35,1939,293],{"class":82},[35,1941,558],{"class":110},[35,1943,1944],{"class":82},".glob(",[35,1946,1947],{"class":45},"\"*.xlsx\"",[35,1949,1950],{"class":82},"), ",[35,1952,812],{"class":245},[35,1954,1955],{"class":78},"=lambda",[35,1957,1958],{"class":82}," p: p.stat().st_mtime)      ",[35,1960,1961],{"class":367},"# least recently used first\n",[35,1963,1964,1967,1969,1972,1975,1978,1980,1983,1985],{"class":37,"line":164},[35,1965,1966],{"class":82},"    now, total ",[35,1968,201],{"class":78},[35,1970,1971],{"class":82}," time.time(), ",[35,1973,1974],{"class":110},"sum",[35,1976,1977],{"class":82},"(p.stat().st_size ",[35,1979,1417],{"class":78},[35,1981,1982],{"class":82}," p ",[35,1984,1423],{"class":78},[35,1986,1987],{"class":82}," files)\n",[35,1989,1990,1993,1995],{"class":37,"line":185},[35,1991,1992],{"class":82},"    removed ",[35,1994,201],{"class":78},[35,1996,1997],{"class":110}," 0\n",[35,1999,2000,2003,2005,2007],{"class":37,"line":190},[35,2001,2002],{"class":78},"    for",[35,2004,1982],{"class":82},[35,2006,1423],{"class":78},[35,2008,2009],{"class":82}," files:\n",[35,2011,2012,2015,2018,2020,2023,2026,2029,2032,2035,2037],{"class":37,"line":210},[35,2013,2014],{"class":78},"        if",[35,2016,2017],{"class":82}," now ",[35,2019,1238],{"class":78},[35,2021,2022],{"class":82}," p.stat().st_mtime ",[35,2024,2025],{"class":78},">",[35,2027,2028],{"class":82}," max_age_s ",[35,2030,2031],{"class":78},"or",[35,2033,2034],{"class":82}," total ",[35,2036,2025],{"class":78},[35,2038,2039],{"class":82}," max_total_bytes:\n",[35,2041,2042,2045,2048],{"class":37,"line":257},[35,2043,2044],{"class":82},"            total ",[35,2046,2047],{"class":78},"-=",[35,2049,2050],{"class":82}," p.stat().st_size\n",[35,2052,2053,2056,2059,2061,2063],{"class":37,"line":271},[35,2054,2055],{"class":82},"            p.unlink(",[35,2057,2058],{"class":245},"missing_ok",[35,2060,201],{"class":78},[35,2062,251],{"class":110},[35,2064,254],{"class":82},[35,2066,2067,2070,2073],{"class":37,"line":276},[35,2068,2069],{"class":82},"            removed ",[35,2071,2072],{"class":78},"+=",[35,2074,2075],{"class":110}," 1\n",[35,2077,2078,2080,2083,2085,2087,2089,2092],{"class":37,"line":287},[35,2079,2002],{"class":78},[35,2081,2082],{"class":82}," tmp ",[35,2084,1423],{"class":78},[35,2086,796],{"class":110},[35,2088,1944],{"class":82},[35,2090,2091],{"class":45},"\"*.tmp\"",[35,2093,207],{"class":82},[35,2095,2096,2098,2100,2102,2105,2107,2110],{"class":37,"line":302},[35,2097,2014],{"class":78},[35,2099,2017],{"class":82},[35,2101,1238],{"class":78},[35,2103,2104],{"class":82}," tmp.stat().st_mtime ",[35,2106,2025],{"class":78},[35,2108,2109],{"class":110}," 3600",[35,2111,2112],{"class":82},":\n",[35,2114,2115,2118,2120,2122,2124],{"class":37,"line":339},[35,2116,2117],{"class":82},"            tmp.unlink(",[35,2119,2058],{"class":245},[35,2121,201],{"class":78},[35,2123,251],{"class":110},[35,2125,254],{"class":82},[35,2127,2128,2130],{"class":37,"line":761},[35,2129,260],{"class":78},[35,2131,2132],{"class":82}," removed\n",[35,2134,2135],{"class":37,"line":772},[35,2136,104],{"emptyLinePlaceholder":103},[35,2138,2139,2141,2143,2146,2149,2152,2154,2156],{"class":37,"line":788},[35,2140,290],{"class":110},[35,2142,293],{"class":82},[35,2144,2145],{"class":45},"\"evicted:\"",[35,2147,2148],{"class":82},", evict(",[35,2150,2151],{"class":245},"max_total_bytes",[35,2153,201],{"class":78},[35,2155,1538],{"class":110},[35,2157,2158],{"class":82},"))\n",[10,2160,2161,2162,2164,2165,2167],{},"When a row changes, the data version changes, so the cache key and the ETag change too: the browser's ",[32,2163,1444],{}," no longer matches, it receives the fresh file with a 200, and the workbook is built a second time. Nothing has to be cleared by hand — old entries simply stop being asked for. They still use disk, which is what eviction is for: a small function, run every hour or so from a scheduler, deletes files not used for a week and, if the cache is still over its size limit, the least recently used files until it fits. It also removes ",[32,2166,921],{}," files left by crashed builds. Calling it with a tiny limit, as here, empties the cache — both versions of the export.",[20,2169,2171],{"id":2170},"when-not-to-cache","When not to cache",[10,2173,2174,2175,2177,2178,18],{},"Caching suits exports that many people request with the same parameters, that are slow to build, and whose data changes less often than they are downloaded — monthly reports, reference lists, dashboards over yesterday's data. It suits per-user exports with unique filters much less: every request has its own key and the cache only fills the disk. A data version also needs a cheap and reliable change signal; if the data has no ",[32,2176,61],{}," column or row count that changes on edits, a short time-based expiry is the honest alternative. For exports that take minutes rather than seconds, caching alone is not enough — the first request still waits — and the work belongs in a background job, as in ",[14,2179,2181],{"href":2180},"\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fgenerate-large-excel-exports-in-a-background-job\u002F","Generate Large Excel Exports in a Background Job",[20,2183,2185],{"id":2184},"common-pitfalls","Common pitfalls",[2187,2188,2189,2197,2203,2212,2218],"ul",{},[2190,2191,2192,2196],"li",{},[2193,2194,2195],"strong",{},"A key without the data version."," Users get old figures after the data changes.",[2190,2198,2199,2202],{},[2193,2200,2201],{},"ETags from the file bytes."," openpyxl's timestamps change the bytes on every build.",[2190,2204,2205,2208,2209,2211],{},[2193,2206,2207],{},"Writing straight to the final name."," Write to a temp file and ",[32,2210,917],{}," it.",[2190,2213,2214,2217],{},[2193,2215,2216],{},"Duplicate builds under load."," Lock per key and check again inside the lock.",[2190,2219,2220,2223,2224,2226],{},[2193,2221,2222],{},"No eviction."," Delete by last use and total size, and clean up ",[32,2225,921],{}," files.",[20,2228,2230],{"id":2229},"conclusion","Conclusion",[10,2232,2233],{},"To cache Excel exports in a web app, key each file on the report, its parameters, a cheap data version and a code version; build it once under a per-key lock and write it atomically; serve it with that key as the ETag so browsers get 304 Not Modified while it is current; and evict files by last use and total size. A change in the data changes the key, so nobody ever downloads stale figures from the cache.",[20,2235,2237],{"id":2236},"frequently-asked-questions","Frequently asked questions",[10,2239,2240,2243,2244,2246],{},[2193,2241,2242],{},"How do I cache a generated Excel file in Flask?","\nCompute a key from the parameters and data version, save the workbook under that key in a cache folder, and serve it with ",[32,2245,1769],{}," on later requests.",[10,2248,2249,2252,2253,2255],{},[2193,2250,2251],{},"How do ETags help with Excel downloads?","\nThe browser sends the ETag back in ",[32,2254,1444],{},"; if the file has not changed, the server replies 304 with no body and the browser reuses its copy.",[10,2257,2258,2261],{},[2193,2259,2260],{},"Why does the same Excel file have different bytes each time?","\nopenpyxl and most writers store the creation time in the workbook properties, so ETags should come from the cache key, not from the file contents.",[10,2263,2264,2267],{},[2193,2265,2266],{},"How do I stop two requests building the same export at once?","\nUse a lock per cache key and check whether the file exists again after acquiring it.",[20,2269,2271],{"id":2270},"related","Related",[2187,2273,2274,2280,2287,2294],{},[2190,2275,2276,2277,2279],{},"Up one level: ",[14,2278,17],{"href":16}," — Excel downloads from web apps.",[2190,2281,2282,2286],{},[14,2283,2285],{"href":2284},"\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Freturn-an-excel-file-from-a-flask-download-endpoint\u002F","Return an Excel File from a Flask Download Endpoint"," — the basic endpoint.",[2190,2288,2289,2293],{},[14,2290,2292],{"href":2291},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fwrite-excel-files-atomically-in-python\u002F","Write Excel Files Atomically in Python"," — the replace pattern.",[2190,2295,2296,2300],{},[14,2297,2299],{"href":2298},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Fcache-api-responses-when-building-excel-reports\u002F","Cache API Responses When Building Excel Reports"," — caching the inputs instead.",[2302,2303,2304],"style",{},"html pre.shiki code .sMTad, html code.shiki .sMTad{--shiki-default:#6F42C1;--shiki-dark:#FFB757}html pre.shiki code .srMev, html code.shiki .srMev{--shiki-default:#032F62;--shiki-dark:#ADDCFF}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);}html pre.shiki code .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .skGVy, html code.shiki .skGVy{--shiki-default:#24292E;--shiki-dark:#F0F3F6}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}",{"title":30,"searchDepth":86,"depth":86,"links":2306},[2307,2308,2309,2310,2311,2312,2313,2314,2315,2316],{"id":22,"depth":86,"text":23},{"id":65,"depth":86,"text":66},{"id":512,"depth":86,"text":513},{"id":925,"depth":86,"text":926},{"id":1776,"depth":86,"text":1777},{"id":2170,"depth":86,"text":2171},{"id":2184,"depth":86,"text":2185},{"id":2229,"depth":86,"text":2230},{"id":2236,"depth":86,"text":2237},{"id":2270,"depth":86,"text":2271},"2026-10-02","Stop a web app rebuilding the same Excel export on every click: key a file cache on the request parameters plus a data version, write cached workbooks atomically, answer repeat downloads with an ETag and 304 Not Modified, keep two users from building the same file at once, and evict old files by age and total size — tested end to end with Flask's test client.","md",[2321,2323,2325,2327],{"q":2242,"a":2322},"Compute a key from the parameters and data version, save the workbook under that key in a cache folder, and serve it with send_file on later requests.",{"q":2251,"a":2324},"The browser sends the ETag back in If-None-Match; if the file has not changed, the server replies 304 with no body and the browser reuses its copy.",{"q":2260,"a":2326},"openpyxl and most writers store the creation time in the workbook properties, so ETags should come from the cache key, not from the file contents.",{"q":2266,"a":2328},"Use a lock per cache key and check whether the file exists again after acquiring it.","cache generated excel files web app",null,{"breadcrumb":2332},[2333,2336,2339],{"name":2334,"item":2335},"Home","\u002F",{"name":2337,"item":2338},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":17,"item":16},"\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fcache-generated-excel-files-in-a-web-app",{"title":2342,"description":2343},"Cache Generated Excel Files in a Python Web App","Cache Excel exports in Flask: cache keys from parameters and data version, atomic writes, ETag and 304 responses, a lock per key against duplicate builds, and eviction by age and size.","cache-generated-excel-files-in-a-web-app","automating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fcache-generated-excel-files-in-a-web-app\u002Findex",[2347,2348],"web-apps","large-files","how-to","jtPIZlUEFfuOUe6lvC5apeRvhjxIcG0uN3dU1gv2G_o",[2352,2356],{"title":2353,"path":2354,"stem":2355,"children":-1},"Build an Excel Workbook in Memory with BytesIO","\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fbuild-an-excel-workbook-in-memory-with-bytesio","automating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fbuild-an-excel-workbook-in-memory-with-bytesio\u002Findex",{"title":2357,"path":2358,"stem":2359,"children":-1},"Download an Excel File from a Dash App","\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fdownload-an-excel-file-from-a-dash-app","automating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fdownload-an-excel-file-from-a-dash-app\u002Findex",[2361,2363],{"path":2362,"title":2337},"\u002Fautomating-reporting-workflows",{"path":2364,"title":17},"\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps",[],[2367,2370,2373,2376,2379,2382],{"path":2368,"title":2369,"kind":2349},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fschedule-excel-reports-with-celery-beat","Schedule Excel Reports with Celery Beat",{"path":2371,"title":2372,"kind":2349},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Ftrigger-an-excel-report-from-a-webhook","Trigger an Excel Report from a Webhook",{"path":2374,"title":2375,"kind":2349},"\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fadd-an-export-to-excel-action-in-django-admin","Add an Export to Excel Action in Django Admin",{"path":2377,"title":2378,"kind":2349},"\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fbuild-a-gradio-app-that-cleans-an-uploaded-excel-file","Build a Gradio App That Cleans an Uploaded Excel File",{"path":2380,"title":2381,"kind":2349},"\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fbuild-a-streamlit-app-to-upload-and-download-excel","Build a Streamlit App to Upload and Download Excel Files",{"path":2354,"title":2353,"kind":2349},1790972140377]