FRP in Python
State of practice as of 2026-09. Foundations (streams over time, effects at the edges, backpressure policy) live in appendix-frp-general.md; this is the Python member of that family, and appendix-streams.md covers the transport side.
Libraries and Building Blocks
reactivex(RxPY) is “a library for composing asynchronous and event-based programs using observable collections and pipable query operators in Python” [1]. Current release 5.1.0, requiring Python>=3.10,<4.0[2].- v5 kept
pipe()and added chaining: operators “are now available as methods on Observable in addition to the existingpipe()style”, with “zero breaking changes from 4.x” [3]. 5.1.0 addedtapas an alias fordo_action[3]. - Subjects bridge imperative events into the graph.
Subjectbroadcasts each notification to all subscribed observers;BehaviorSubject“Represents a value that changes over time” and hands new observers the last or initial value;ReplaySubjectalso serves future observers, “subject to buffer trimming policies”;AsyncSubjectemits only the last value before close [4]. - Schedulers decide where work runs.
AsyncIOSchedulerandAsyncIOThreadSafeSchedulerlive inreactivex.scheduler.eventloop;TrampolineScheduler,CurrentThreadScheduler,ThreadPoolScheduler,VirtualTimeSchedulerandHistoricalSchedulerlive inreactivex.scheduler[5]. “You should never schedule timeouts using the TrampolineScheduler, as it will block the thread while waiting” [5]. - Push and pull are duals, and Python has both. aioreactive puts it directly:
AsyncObservable“may be seen as the dual or opposite of AsyncIterable”, andto_async_iterablelets you “flip around” toasync for; because “a push by theAsyncObservablewill await the pull by theAsyncIterator”, that conversion is itself backpressure [25]. aioreactive’s latest release is 0.20.0 from 2024-09-28 [26], so it is the quiet branch, not the default choice. - anyio memory object streams are the async-agnostic channel:
create_memory_object_stream(max_buffer_size: float = 0, item_type: object = None)[10]. “By default, memory object streams are created with a buffer size of 0. This means thatsend()will block until there’s another task that callsreceive()” [9]. An unbounded buffer viamath.infis possible but “not recommended” [9]. Streams clone, and each end “is only considered closed once all of its clones have been closed” [9]. - trio channels are the same shape:
open_memory_channel(max_buffer_size)takes an int ormath.inf, and “Choosing a sensible value here is important to ensure that backpressure is communicated promptly and avoid unnecessary latency … If in doubt, use 0” [12].statistics()exposescurrent_buffer_usedandpeak_buffer_usedfor queue-depth metrics [12]. - Signal libraries are callbacks, not observables. blinker “provides fast & simple object-to-object and broadcast signaling for Python objects” with receivers “automatically disconnected … via weak referencing”;
send()calls receivers andsend_async()awaits them [24]. psygnal is a “Pure python callback/event system modeled after Qt Signals”, compiled with mypyc because “signals are often emitted frequently”, and converts dataclasses, attrs, pydantic or msgspec objects into evented ones [23]. Neither offers operators, so neither composes into a graph. - Typed results at the boundary:
returnssuppliesResultwithSuccessandFailure, a@safedecorator “to convert exception-throwing function to Result container”, and.unwrap()/.value_or(default)for extraction [22]. The stdlib alternative is a frozen dataclass, which@dataclass(frozen=True)makes immutable and hashable [28], discriminated bymatch; the example below takes that route.
Core Patterns
- Intent -> reducer -> effect: a hot
Subjectof intents [4],scanfolding to state, and an effect layer that maps intents back into intents [1]. - Backpressure is a bounded buffer chosen up front.
asyncio.Queue: “If maxsize is less than or equal to zero, the queue size is infinite. If it is an integer greater than0, thenawait put()blocks when the queue reaches maxsize until an item is removed byget()” [7]. anyio and trio expose the same knob on their streams [9][12]. - Cancellation is structured concurrency, not a flag.
asyncio.TaskGroup(added 3.11) cancels the remaining tasks “The first time any of the tasks belonging to the group fails with an exception other thanasyncio.CancelledError”, then combines failures in anExceptionGroup[8].asyncio.timeout(added 3.11) “will cancel the current task and handle the resultingasyncio.CancelledErrorinternally, transforming it into aTimeoutError” [8]. On anyio useCancelScope,move_on_afterandfail_after[10]; trio has the same three, wheremove_on_afterswallowsCancelledandfail_afterraisesTooSlowError[12]. - Never swallow
CancelledError. “If end-user code is suppressing cancellation by catchingCancelledError, it needs to call this method [uncancel()] to remove the cancellation state” [8]; otherwise task groups and timeouts silently misbehave. - Shutdown is explicit.
Queue.shutdown()(added 3.13) means “Future calls toput()raiseQueueShutDown” and, “Once the queue is empty, future calls toget()will raiseQueueShutDown” [7]. Pair it withtake_until, which “Returns the values from the source observable sequence until the other observable sequence produces a value” [27], so the reactive half completes at the same instant the queue closes. - Eager tasks remove scheduling overhead for effects that finish without blocking: with
loop.set_task_factory(asyncio.eager_task_factory), “coroutines begin execution synchronously duringTaskconstruction. Tasks are only scheduled on the event loop if they block”; added 3.12 [8]. - Effects stay at the edges. Operators are “pipable query operators” over a sequence [1], and “Blocking (CPU-bound) code should not be called directly” on the loop [29], so IO belongs in tasks that feed results back as intents rather than inside a
map. - Time is a scheduler, not a
sleep.VirtualTimeScheduler“should work with either datetime/timespan or ticks as int/int” andHistoricalScheduler“uses datetime for absolute time and timedelta for relative time” [5]; on trio,MockClockdefaults to rate 0.0, so “the clock only advances through manuals calls tojump()” [13].
The same boundary in anyio form, with no reactivex in the pipeline. MemoryObjectStreamStatistics reports current_buffer_used, max_buffer_size, open_send_streams, open_receive_streams, tasks_waiting_send and tasks_waiting_receive [10], so the peak below is computed here rather than read from a field. Runnable as written:
import anyiofrom anyio.streams.memory import MemoryObjectReceiveStream
async def fold(recv: MemoryObjectReceiveStream[int]) -> None: total = 0 async for item in recv: await anyio.sleep(0.001) # a slow consumer, so the bound actually bites total += item print("total", total)
async def main() -> None: # The bound is the backpressure policy: send() waits once the buffer is full. send, recv = anyio.create_memory_object_stream[int](max_buffer_size=8) high_water = 0 async with anyio.create_task_group() as tg: tg.start_soon(fold, recv) with send: for i in range(50): await send.send(i) stats = send.statistics() high_water = max(high_water, stats.current_buffer_used) print("max_buffer_size 8, observed current_buffer_used peak", high_water)
anyio.run(main)Example: asyncio + reactivex intent loop
reactivex supplies the operator algebra and asyncio supplies the concurrency: AsyncIOScheduler takes the running loop [5], while the bounded queue [7], the task group and the timeout [8] stay stdlib. Verified in this repo on CPython 3.13.5 with reactivex 5.1.0 [2]: it runs to completion and exits 0.
"""Intent -> reducer -> effect loop on asyncio + reactivex. Python 3.13."""
import asynciofrom dataclasses import dataclass, replace
from reactivex import Subjectfrom reactivex import operators as opsfrom reactivex.scheduler.eventloop import AsyncIOScheduler
@dataclass(frozen=True, slots=True)class Load: key: str
@dataclass(frozen=True, slots=True)class Loaded: key: str value: str
@dataclass(frozen=True, slots=True)class Failed: key: str reason: str
Intent = Load | Loaded | Failed
@dataclass(frozen=True, slots=True)class State: status: str = "idle" ok: tuple[str, ...] = () err: tuple[str, ...] = ()
def reduce(state: State, intent: Intent) -> State: """Pure, total over the Intent union. No IO, no awaits, no exceptions.""" match intent: case Load(): return replace(state, status="loading") case Loaded(key=key, value=value): return replace(state, status="ready", ok=(*state.ok, f"{key}={value}")) case Failed(key=key, reason=reason): return replace(state, status="error", err=(*state.err, f"{key}:{reason}"))
DELAYS = {"alpha": 0.01, "beta": 0.02, "boom": 0.03}
async def fetch(key: str) -> str: """The only IO in the program. It lives at the edge, never in an operator.""" await asyncio.sleep(DELAYS[key]) if key == "boom": raise RuntimeError("upstream refused") return f"value-{key}"
async def main() -> None: scheduler = AsyncIOScheduler(asyncio.get_running_loop()) inbox: asyncio.Queue[Intent] = asyncio.Queue(maxsize=2) # the backpressure boundary intents: Subject[Intent] = Subject() shutdown: Subject[None] = Subject() done = asyncio.Event()
states = intents.pipe( ops.scan(reduce, State()), ops.distinct_until_changed(), ops.take_until(shutdown), ops.share(), )
def observe(state: State) -> None: print(f"state: {state.status} ok={list(state.ok)} err={list(state.err)}") if len(state.ok) + len(state.err) == len(DELAYS): done.set()
state_sub = states.subscribe( on_next=observe, on_completed=lambda: print("graph completed"), )
async with asyncio.TaskGroup() as group:
async def run_effect(intent: Load) -> None: try: value = await fetch(intent.key) except RuntimeError as exc: await inbox.put(Failed(intent.key, str(exc))) else: await inbox.put(Loaded(intent.key, value))
effects = intents.pipe( ops.filter(lambda intent: isinstance(intent, Load)), ops.observe_on(scheduler), # effect tasks are created on the loop ) effect_sub = effects.subscribe( on_next=lambda intent: group.create_task(run_effect(intent)) )
async def pump() -> None: """Drain the bounded inbox into the hot intent stream.""" while True: try: intent = await inbox.get() except asyncio.QueueShutDown: # Python 3.13 return intents.on_next(intent) inbox.task_done()
group.create_task(pump()) for key in DELAYS: await inbox.put(Load(key))
try: async with asyncio.timeout(2.0): await done.wait() except TimeoutError: print("timed out waiting for effects")
inbox.shutdown() # unblocks pump with QueueShutDown shutdown.on_next(None) # completes the state graph via take_until effect_sub.dispose()
state_sub.dispose()
if __name__ == "__main__": asyncio.run(main())Integration Notes
- Textual already is an FRP shell. Reactive attributes are declared with
reactive(orvarwhen no refresh is wanted), and assignment triggers an automatic refresh;watch_<name>methods react to changes,validate_<name>methods intercept and clamp incoming values,compute_<name>methods derive cached values from other reactives,layout=Trueandrecompose=Truewiden what a change re-renders, anddata_bind()propagates a parent reactive into children [15]. Mutating a collection inside a reactive needsmutate_reactive()to fire the machinery [15], anddata_bind()is one-directional, parent to child [15]. - Starlette is where a stream meets the wire.
StreamingResponse“Takes an async generator or a normal generator/iterator and streams the response body” [16]; server-sent events are third party, viaEventSourceResponsefromsse-starlette[16]. TheWebSocketclass “fulfils a similar role to the HTTP request, but … allows sending and receiving data on a websocket”, withiter_text(),iter_bytes()anditer_json()async iterators that exit onWebSocketDisconnect[17]. Those iterators are the natural intent source; a bounded per-connection queue is the natural outbound sink [7]. - Django signals are not FRP. They “allow certain senders to notify a set of receivers that some action has taken place”, are “called one at a time, in the order they were registered”, and adapt across the sync boundary: “Synchronous receivers will be called using
sync_to_async()when invoked viaasend(). Asynchronous receivers will be called usingasync_to_sync()when invoked viasend()” [18]. “All built-in signals, except those in the async request-response cycle, are dispatched usingSignal.send()” [18], so they are in-process callbacks with no operators, no backpressure and no cancellation. - Data pipelines without observables: aiostream is “a collection of stream operators that can be combined to create asynchronous pipelines of operations”, an “asynchronous version of itertools”, with pipelining via
|, repeatability, a safe iteration context viaasync withand thestreammethod, slicing, and concatenation via+[19].
Testing
- Virtual time in reactivex:
from reactivex.testing import ReactiveTest, TestScheduler[6].scheduler.start()usescreated100,subscribed200 anddisposed1000 by default [6]. Notifications are built withReactiveTest.on_next(time, value),on_completed(time)andon_error(time, exception)[6]. Marble diagrams are available throughmarbles_testing(), which yieldsstart, cold, hot, exp[6].
from reactivex import operators as opsfrom reactivex.testing import ReactiveTest, TestScheduler
def test_scan_folds_intents() -> None: scheduler = TestScheduler() source = scheduler.create_hot_observable( ReactiveTest.on_next(210, 1), ReactiveTest.on_next(240, 2), ReactiveTest.on_completed(300), ) results = scheduler.start(lambda: source.pipe(ops.scan(lambda acc, x: acc + x, 0))) assert results.messages == [ ReactiveTest.on_next(210, 1), ReactiveTest.on_next(240, 3), ReactiveTest.on_completed(300), ]- Async test runners: pytest-asyncio offers strict and auto discovery modes, where “In strict mode pytest-asyncio will only run tests that have the asyncio marker and will only evaluate async fixtures decorated with
@pytest_asyncio.fixture”, auto mode “automatically adds the asyncio marker to all asynchronous test functions”, and “strict mode is the default mode” [20]. AnyIO ships its own plugin: mark withpytest.mark.anyioor request theanyio_backendfixture, and parameterise that fixture overasyncioandtrioto run the same suite on both backends [11]. - Deterministic clocks on trio:
MockClock(rate=0.0, autojump_threshold=math.inf)advances only viajump(seconds), or automatically once “all tasks have been blocked for this many real seconds” if a threshold is set [13].wait_all_tasks_blocked()“block[s] until there are no runnable tasks”, which is how you let a graph settle before asserting [13]. - Property tests over event sequences: Hypothesis
RuleBasedStateMachinegenerates sequences of@ruleactions rather than single inputs,Bundlecarries generated values between rules,@invariantmarks “a function to be run after every step”, and@preconditionfilters inapplicable rules; run it via the machine’s.TestCaseattribute orrun_state_machine_as_test()[21].
Anti-Patterns
- Blocking inside an operator: “if a function performs a CPU-intensive calculation for 1 second, all concurrent asyncio Tasks and IO operations would be delayed by 1 second” [29]. Push that work to
loop.run_in_executor()[29]. - Catching
CancelledErrorand returning normally withoutuncancel(), which breaks the enclosing task group or timeout [8]. yieldinside aTaskGrouporasyncio.timeoutin an async generator: PEP 789 (status Draft) documents that this “leads to situations where the wrong task is canceled, timeouts are ignored, and exceptions are mishandled” [14].- Unbounded buffers:
asyncio.Queue()with the defaultmaxsizeis infinite [7], andmath.infon an anyio stream is explicitly “not recommended” [9]. - Timeouts on
TrampolineSchedulerorCurrentThreadScheduler, both of which block the thread while waiting [5]. - Treating blinker or psygnal as an event bus for domain flow: synchronous callbacks with no operators, no backpressure and no cancellation [23][24]. The same objection applies to Django signals [18].
- Unbounded
ReplaySubjectused as a cache: it serves “future observers, subject to buffer trimming policies” [4], so set the policy.
Checklist
- One scheduler chosen per graph, and an
AsyncIOSchedulerbound to the running loop wherever operator work touches asyncio [5]. - Every queue and stream given an explicit bound, with
statistics()orqsize()exported as a metric [7][9][12]. - Cancellation path proven: a task group or cancel scope owns every effect, and a shutdown signal completes the reactive half [8][10][12].
- Errors modelled as domain values (
Result, or a frozen dataclass in the intent union) rather than exceptions crossing operators [22]. - Reducers pure and total, tested table-driven, with a Hypothesis state machine over intent sequences [21].
- Virtual time in tests, never
sleep[6][13].
Agreed vs folklore (compressed)
- Agreed: backpressure is a bounded buffer, and every mainstream Python channel says so in its own docs [7][9][12]; cancellation belongs to structured concurrency primitives rather than ad hoc flags [8][10][12][14]; time in tests is a scheduler or clock, not a sleep [6][13].
- “asyncio is FRP”: false as stated. asyncio supplies queues, tasks, cancel scopes and timeouts [7][8], and none of the operator algebra (
scan,share,distinct_until_changed,take_until) that the family doc treats as the point; that algebra comes from reactivex [1] or aiostream [19]. - “RxPY is dead”: false. reactivex 5.1.0 is current on PyPI [2] and v5 added method chaining with “zero breaking changes from 4.x” [3]. The dormant project is aioreactive, last released 2024-09-28 [26].
- “you need Rx to do reactive in Python”: false. Textual ships reactive descriptors, watchers, validators and computed attributes with no observable library [15]; anyio and trio give push streams with real backpressure [9][12]; aiostream gives operator pipelines over async iterators [19].
Synthesis (inferred)
- Reach for plain async generators plus
TaskGroupfirst. One producer, one consumer, a linear transform and a bounded queue cover most services, and every piece is stdlib, debuggable and typed. Reach for reactivex when the graph is genuinely a graph: multiple subscribers on one source, latest-wins semantics (flat_map_latestorswitch_latest), fan-in from heterogeneous sources, or time operators (debounce,sample,buffer_with_time) that you would otherwise hand-roll with timers. The break-even point in practice is the second subscriber or the first time-shaping operator. - Do not let reactivex own concurrency. Keep the loop, the queues, the task group and the timeouts in asyncio, and use
AsyncIOScheduleronly to place operator work on that loop. The moment the Observable graph is also the scheduler, cancellation stops being inspectable from the asyncio side. - Migration path out of callback soup. (1) Name the intents: every callback argument becomes a frozen dataclass in one union. (2) Insert a bounded queue between the callback and the logic, so the callback only enqueues. (3) Move all state mutation into one pure reducer driven by that queue, keeping the old callbacks as thin adapters. (4) Move IO out of the reducer into effect tasks that enqueue result intents. (5) Only then, if the graph has branched, replace the hand-written pump with
Subjectplusscanplusshare. Steps 1 to 4 are worth doing even if step 5 never happens. - Textual, Django and Starlette are shells, not cores. Bind them to the reducer at the edge (reactive attribute writes, signal handlers, websocket iterators) and the same domain graph runs headless under test with a virtual clock.