Skip to content

Instantly share code, notes, and snippets.

@dhermes
Last active March 24, 2026 20:40
Show Gist options
  • Select an option

  • Save dhermes/5b3afc13ca67eee69fe062dbdd1634d5 to your computer and use it in GitHub Desktop.

Select an option

Save dhermes/5b3afc13ca67eee69fe062dbdd1634d5 to your computer and use it in GitHub Desktop.
[2026-03-24] [email protected] globals + child threads

Handling flask.g in a child thread

Solution

In order to spawn a child thread via _EXECUTOR.submit() or threading.Thread(target=...), the target function needs to be wrapped with flask.copy_current_request_context(). That only copies the Flask context, and does not copy flask.g. So in order to preserve flask.g as well we need to copy flask.g.__dict__ and then place it back on the flask.g in the new context. This is nuts!

def _make_task_closure(func: Callable[P, R]) -> Callable[P, R]:
    g_values = flask.g.__dict__.copy()

    @functools.wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        # NOTE: The `wrapper()` is expected to be called in a new execution context, so `flask.g`
        #       below is within that context, not within the context that **WRAPPED** `func`.
        flask.g.__dict__.update(g_values)
        return func(*args, **kwargs)

    return flask.copy_current_request_context(wrapper)

Evidence

Running the app via uv run via_flask_g.py we can see the impact of the copy (or the absence of the copy).

When flask.g.__dict__ gets updated, we see the executor_result and thread_result reflecting that they are exactly incremented from the squishy value:

$ curl --include http://localhost:5001/
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 75
Server: Werkzeug/2.0.3 Python/3.12.12
Date: Tue, 24 Mar 2026 19:39:17 GMT

{"copy":true,"executor_result":78655,"squishy":8655,"thread_result":18655}

and when we skip the copy, they fall back to default values:

$ curl --include http://localhost:5001/?copy=false
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 72
Server: Werkzeug/2.0.3 Python/3.12.12
Date: Tue, 24 Mar 2026 19:39:19 GMT

{"copy":false,"executor_result":42,"squishy":6290,"thread_result":1337}

Including contextvars too

In addition to copying the Flask context, looking to the future we may also want to capture contextvars:

def _make_task_closure(func: Callable[P, R]) -> Callable[P, R]:
    outer_ctx = contextvars.copy_context()
    g_values = flask.g.__dict__.copy()

    @functools.wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        flask.g.__dict__.update(g_values)
        return outer_ctx.run(func, *args, **kwargs)

    return flask.copy_current_request_context(wrapper)

We can confirm that pulling either from contextvars or from flask.g we get the desired outcome here:

$ curl --include 'http://localhost:5002/?source=contextvars'
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 98
Server: Werkzeug/2.0.3 Python/3.12.12
Date: Tue, 24 Mar 2026 20:39:07 GMT

{"copy":true,"executor_result":77802,"source":"contextvars","squishy":7802,"thread_result":17802}
$
$
$ curl --include 'http://localhost:5002/?source=flask.g'
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 94
Server: Werkzeug/2.0.3 Python/3.12.12
Date: Tue, 24 Mar 2026 20:39:09 GMT

{"copy":true,"executor_result":73418,"source":"flask.g","squishy":3418,"thread_result":13418}

and if we set ?copy=false, both of contextvars and flask.g leave the value unset

$ curl --include 'http://localhost:5002/?source=contextvars&copy=false'
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 95
Server: Werkzeug/2.0.3 Python/3.12.12
Date: Tue, 24 Mar 2026 20:39:28 GMT

{"copy":false,"executor_result":42,"source":"contextvars","squishy":2798,"thread_result":1337}
$
$
$ curl --include 'http://localhost:5002/?source=flask.g&copy=false'
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 91
Server: Werkzeug/2.0.3 Python/3.12.12
Date: Tue, 24 Mar 2026 20:39:33 GMT

{"copy":false,"executor_result":42,"source":"flask.g","squishy":3488,"thread_result":1337}
[project]
name = "5b3afc13ca67eee69fe062dbdd1634d5"
version = "0.0.1"
requires-python = "==3.12.12"
dependencies = [
"flask==1.1.1",
"itsdangerous==2.0.1",
"jinja2==3.0.3",
"werkzeug==2.0.3",
]
version = 1
revision = 3
requires-python = "==3.12.12"
[[package]]
name = "5b3afc13ca67eee69fe062dbdd1634d5"
version = "0.0.1"
source = { virtual = "." }
dependencies = [
{ name = "flask" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "werkzeug" },
]
[package.metadata]
requires-dist = [
{ name = "flask", specifier = "==1.1.1" },
{ name = "itsdangerous", specifier = "==2.0.1" },
{ name = "jinja2", specifier = "==3.0.3" },
{ name = "werkzeug", specifier = "==2.0.3" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "flask"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2e/80/3726a729de758513fd3dbc64e93098eb009c49305a97c6751de55b20b694/Flask-1.1.1.tar.gz", hash = "sha256:13f9f196f330c7c2c5d7a5cf91af894110ca0215ac051b5844701f2bfd934d52", size = 625458, upload-time = "2019-07-08T18:00:31.166Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/93/628509b8d5dc749656a9641f4caf13540e2cdec85276964ff8f43bbb1d3b/Flask-1.1.1-py2.py3-none-any.whl", hash = "sha256:45eb5a6fd193d6cf7e0cf5d8a5b31f83d5faae0293695626f539a823e93b13f6", size = 94457, upload-time = "2019-07-08T18:00:28.597Z" },
]
[[package]]
name = "itsdangerous"
version = "2.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/66/d6c5859dcac92b442626427a8c7a42322068c5cd5d4a463ce78b93f730b7/itsdangerous-2.0.1.tar.gz", hash = "sha256:9e724d68fc22902a1435351f84c3fb8623f303fffcc566a4cb952df8c572cff0", size = 59336, upload-time = "2021-05-18T15:09:44.532Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/96/26f935afba9cd6140216da5add223a0c465b99d0f112b68a4ca426441019/itsdangerous-2.0.1-py3-none-any.whl", hash = "sha256:5174094b9637652bdb841a3029700391451bd092ba3db90600dea710ba28e97c", size = 18326, upload-time = "2021-05-18T15:09:42.542Z" },
]
[[package]]
name = "jinja2"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/91/a5/429efc6246119e1e3fbf562c00187d04e83e54619249eb732bb423efa6c6/Jinja2-3.0.3.tar.gz", hash = "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7", size = 269196, upload-time = "2021-11-09T20:27:29.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/9a/e5d9ec41927401e41aea8af6d16e78b5e612bca4699d417f646a9610a076/Jinja2-3.0.3-py3-none-any.whl", hash = "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8", size = 133630, upload-time = "2021-11-09T20:27:27.116Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
]
[[package]]
name = "werkzeug"
version = "2.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6c/a8/60514fade2318e277453c9588545d0c335ea3ea6440ce5cdabfca7f73117/Werkzeug-2.0.3.tar.gz", hash = "sha256:b863f8ff057c522164b6067c9e28b041161b4be5ba4d0daceeaa50a163822d3c", size = 895551, upload-time = "2022-02-07T21:04:39.14Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/f3/22afbdb20cc4654b10c98043414a14057cd27fdba9d4ae61cea596000ba2/Werkzeug-2.0.3-py3-none-any.whl", hash = "sha256:1421ebfc7648a39a5c58c601b154165d05cf47a3cd0ccb70857cbdacf6c8f2b8", size = 289232, upload-time = "2022-02-07T21:04:36.336Z" },
]
import concurrent.futures
import contextvars
import functools
import random
import threading
from collections.abc import Callable
from typing import Literal, ParamSpec, TypeVar
import flask
P = ParamSpec("P")
R = TypeVar("R")
app = flask.Flask(__name__)
_EXECUTOR = concurrent.futures.ThreadPoolExecutor(max_workers=1)
_SQUISHY = contextvars.ContextVar[int | None]("squishy", default=None)
_Source = Literal["contextvars", "flask.g"]
def _determine_copy(copy_args: list[str]) -> bool:
if len(copy_args) != 1:
return True
copy_arg = copy_args[0].lower()
return copy_arg in ("1", "true", "yes")
def _determine_source(source_args: list[str]) -> _Source:
if len(source_args) != 1:
return "contextvars"
source_arg = source_args[0].lower()
if source_arg != "flask.g":
return "contextvars"
return "flask.g"
def _maybe_get_squishy(source: _Source) -> int | None:
if source == "contextvars":
value = _SQUISHY.get()
elif source == "flask.g":
value = getattr(flask.g, "_squishy", None)
else:
raise NotImplementedError("Unsupported source", source)
if value is None:
return None
if not isinstance(value, int):
raise TypeError("Squishy must be integer", type(value), value)
return value
def _get_squishy(source: _Source) -> int:
squishy = _maybe_get_squishy(source)
if squishy is None:
raise ValueError("Required squishy")
return squishy
@app.before_request
def _before_request_hook() -> None:
squishy = random.randint(1000, 9999)
flask.g._squishy = squishy
_SQUISHY.set(squishy)
class _Container:
def __init__(self) -> None:
self.value: int | None = None
def _thread_func(container: _Container, source: _Source) -> None:
squishy = _maybe_get_squishy(source)
if squishy is None:
container.value = 1337
else:
container.value = squishy + 10000
def _executor_func(source: _Source) -> int:
squishy = _maybe_get_squishy(source)
if squishy is None:
return 42
return squishy + 70000
def _make_task_closure(func: Callable[P, R], copy: bool) -> Callable[P, R]:
if not copy:
return flask.copy_current_request_context(func)
outer_ctx = contextvars.copy_context()
g_values = flask.g.__dict__.copy()
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
flask.g.__dict__.update(g_values)
return outer_ctx.run(func, *args, **kwargs)
return flask.copy_current_request_context(wrapper)
@app.route("/")
def hello_world():
copy = _determine_copy(flask.request.args.getlist("copy"))
source = _determine_source(flask.request.args.getlist("source"))
squishy = _get_squishy(source)
copied_executor_func = _make_task_closure(_executor_func, copy=copy)
future = _EXECUTOR.submit(copied_executor_func, source)
executor_result = future.result()
container = _Container()
copied_thread_func = _make_task_closure(_thread_func, copy=copy)
t = threading.Thread(target=copied_thread_func, args=(container, source))
t.start()
t.join()
thread_result = container.value
return {
"copy": copy,
"source": source,
"squishy": squishy,
"executor_result": executor_result,
"thread_result": thread_result,
}
def main() -> None:
port = 5002
host = "0.0.0.0"
app.run(host=host, port=port)
if __name__ == "__main__":
main()
# uv run via_contextvars.py
import concurrent.futures
import contextvars
import functools
import random
import threading
from collections.abc import Callable
from typing import ParamSpec, TypeVar
import flask
P = ParamSpec("P")
R = TypeVar("R")
app = flask.Flask(__name__)
_EXECUTOR = concurrent.futures.ThreadPoolExecutor(max_workers=1)
_SQUISHY = contextvars.ContextVar[int | None]("squishy", default=None)
def _determine_copy(copy_args: list[str]) -> bool:
if len(copy_args) != 1:
return True
copy_arg = copy_args[0].lower()
return copy_arg in ("1", "true", "yes")
def _maybe_get_squishy() -> int | None:
value = _SQUISHY.get()
if value is None:
return None
if not isinstance(value, int):
raise TypeError("Squishy must be integer", type(value), value)
return value
def _get_squishy() -> int:
squishy = _maybe_get_squishy()
if squishy is None:
raise ValueError("Required squishy")
return squishy
@app.before_request
def _before_request_hook() -> None:
squishy = random.randint(1000, 9999)
flask.g._squishy = squishy
_SQUISHY.set(squishy)
class _Container:
def __init__(self) -> None:
self.value: int | None = None
def _thread_func(container: _Container) -> None:
squishy = _maybe_get_squishy()
if squishy is None:
container.value = 1337
else:
container.value = squishy + 10000
def _executor_func() -> int:
squishy = _maybe_get_squishy()
if squishy is None:
return 42
return squishy + 70000
def _make_task_closure(func: Callable[P, R], copy: bool) -> Callable[P, R]:
g_values = flask.g.__dict__.copy()
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
# NOTE: The `wrapper()` is expected to be called in a new execution context, so `flask.g`
# below is within that context, not within the context that **WRAPPED** `func`.
if copy:
flask.g.__dict__.update(g_values)
return func(*args, **kwargs)
return flask.copy_current_request_context(wrapper)
@app.route("/")
def hello_world():
copy = _determine_copy(flask.request.args.getlist("copy"))
squishy = _get_squishy()
copied_executor_func = _make_task_closure(_executor_func, copy=copy)
future = _EXECUTOR.submit(copied_executor_func)
executor_result = future.result()
container = _Container()
copied_thread_func = _make_task_closure(_thread_func, copy=copy)
t = threading.Thread(target=copied_thread_func, args=(container,))
t.start()
t.join()
thread_result = container.value
return {
"copy": copy,
"squishy": squishy,
"executor_result": executor_result,
"thread_result": thread_result,
}
def main() -> None:
port = 5001
host = "0.0.0.0"
app.run(host=host, port=port)
if __name__ == "__main__":
main()
# uv run via_flask_g.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment