# Use \`Core.stdout\` and \`Core.stderr\` per default

**URL:** https://discourse.julialang.org/t/use-core-stdout-and-core-stderr-per-default/134256
**Category:** General Usage
**Created:** [December 1, 2025, 1:11pm UTC](https://discourse.julialang.org/t/use-core-stdout-and-core-stderr-per-default/134256 "2025-12-01T13:11:16Z")
**Posts on this page:** 2
**Page:** 1

<div class="post-metadata">

### Author: ![PatrickHaecker](https://sea2.discourse-cdn.com/julialang/user_avatar/discourse.julialang.org/patrickhaecker/32/222891_2.png) [@PatrickHaecker](https://discourse.julialang.org/u/PatrickHaecker)
#### Post date: [December 1, 2025, 1:11pm UTC](https://discourse.julialang.org/t/use-core-stdout-and-core-stderr-per-default/134256/1 "2025-12-01T13:11:16Z")

</div>

When using the `--trim` functionality, it quickly becomes inconvenient to always write the `io` to be `Core.stdout` when the non-trimmable `Base.stdout` was so convient as the default parameter (I focus here on `stdout`, but the same goes for `stderr`).

So how can we use `Core.stdout` as default in programs which will be trimmed?

We can make use of world age and overwrite the corresponding functions from within our app module:

```julia
module MyApp
Base.print(x) = Base.print(Core.stdout, x)
Base.display(x) = Base.display(Core.stdout |> TextDisplay, x)
[…]

```

From now on we can write

```julia
    print(5)
    display(5)

```

while still being trimmable. This is nice, but only a fraction of the methods which need to be overwritten. Ah, and this leads to a direct dependency on a specific Julia patch version and to a lot of test cases you should implement, because this is worse than type piracy. How do you call this? Type terrorism?

Is there a more complete way to achieve this? The root cause is that `Base.stdout` needs to get the “correct” type (i.e. what it actually has and not the abstract `IO`).

- Could we somehow list the `Union` of types which `Base.stdout` actually can have and tell Julia to union-split/world-split until this `Union` is covered?
- Can we somehow change the type of `Base.stdout` to `Core.CoreSTDOUT`?
- Can we somehow alias `Base.stdout` to point to `Core.stdout`?
- Can we introduce a command line flag to Julia to implement something like this?

```julia
module MyApp
Core.eval(Base, :(stdout = Core.stdout))
[…]

```

does not work (“Evaluation into the closed module `Base` breaks incremental compilation because the side effects will not be permanent. This is likely due to some other module mutating `Base` with `eval` during precompilation - don’t do this”). Julia is certainly right with “don’t do this”, but for trimmed compilation a problem with precompilation seems to be acceptable and this would be convenient.

Any ideas how to get trimmability and convenience?

---

<div class="post-metadata">

### Author: ![PatrickHaecker](https://sea2.discourse-cdn.com/julialang/user_avatar/discourse.julialang.org/patrickhaecker/32/222891_2.png) [@PatrickHaecker](https://discourse.julialang.org/u/PatrickHaecker)
#### Post date: [June 23, 2026, 3:26pm UTC](https://discourse.julialang.org/t/use-core-stdout-and-core-stderr-per-default/134256/2 "2026-06-23T15:26:37Z")

</div>

Coming back to this after some discussion with Claude which I kind of forced my ideas into, where you can see the summary from (written by Claude Opus 4.8, edited by me)

## The actual root cause

`Base.stdout` is declared `stdout::IO` — an _abstract_ binding type. The concrete type isn’t known until ` __init__ `, where `init_stdio` probes fd 1 and returns one of a **small closed set** : `IOStream` (file), `TTY` (terminal), `TCPSocket`, or `PipeEndpoint` (pipe), optionally wrapped in `IOContext` when color is on. Because the declared type is the open `::IO`, the trimmer must assume “anything `<: IO`” and bails as the type is unbounded.

## Why `Core.stdout` works but is not perfect

`Core.stdout`/`CoreSTDOUT` is a single fixed type whose tty/file/pipe distinction is pushed into C (`jl_uv_puts`), so it devirtualizes perfectly and handles pipes fine. But as noted upthread, it means reworking call sites and you lose `IOContext` features (`:color`, `:displaysize`, …). It’s the “collapse to one type” extreme: smallest binary, biggest rework.

## The alternative: just narrow the binding type under trim

You don’t need to collapse the hierarchy, and you don’t even need `const`. A typed global enforces its declared type on _every_ assignment, so if under `--trim` the binding is declared as the closed union it can actually hold:

```julia
stdout::Union{IOStream, TTY, TCPSocket, PipeEndpoint,
              IOContext{IOStream}, IOContext{TTY},
              IOContext{TCPSocket}, IOContext{PipeEndpoint}}

```

then inference sees a finite concrete set at every `print` site and union-splits into a bounded switch. Advantages over const:

- **`redirect_stdout` keeps working** , as long as the target is in the union (file/pipe/tty/socket — the realistic cases). Only redirect-to-arbitrary-`IO` (e.g. an `IOBuffer`) would now throw instead of silently widening.
- It’s a localized change (narrow the binding + give `init_stdio` a concrete-union return type), not an IO redesign.

Cost: a constant-factor binary increase, since the write/show stack is retained for ~4 backends instead of 1. The value-type axis stays finite too (your printed types + their transitive `show` closure), which for a typical CLI is modest.

## On the `IOContext` half

Note that `IOContext` unqualified is `IOContext{T} where T` (infinite leaves), so it has to be expanded to the realistic non-nested wraps as above. And whether `IOContext` is present at all is currently a **runtime** decision — `_reinit_stdio` wraps only when `--color` is set and fd 1 isn’t a TTY. But that decision is _central and startup-fixed_, not per-call. So the juliac build (which already overrides `reinit_stdio`) can pin the color policy at build time; commit to “no color” and the `IOContext` members drop out entirely, halving the union. _Which_ concrete backend you get is an unavoidable runtime probe; _whether `IOContext` is in the union_ is a build-time policy you control.

## TL;DR

A possible fix is narrowing the **declared binding type** of `stdout`/`stderr` to their closed concrete union under `--trim`. That makes the default sink statically union-splittable, keeps fd/file/pipe redirection working, and costs only a bounded constant-factor in binary size. Pinning the color policy in the trim build removes the `IOContext` half on top of that.

## `Base.stdout::Core.CoreSTDOUT` vs. the narrowed-union proposal

These are really the same family — “make the declared binding type concrete enough for the trimmer” — but at opposite ends. The key realization is that `stdout::CoreSTDOUT` is just the `Core.stdout` outcome reached by _retyping the binding instead of rewriting call sites_. Since user code already reads the global `stdout`, retyping the binding is strictly nicer than the original `Core.stdout` suggestion: you get the single-concrete-type benefit with **zero call-site changes**. So it dominates the plain `Core.stdout` proposal.

The interesting comparison is therefore `stdout::CoreSTDOUT` (collapse to one type) vs. the narrowed union (keep the hierarchy, just bound it).

| | `stdout::CoreSTDOUT` | Narrowed union |
| --- | --- | --- |
| Declared type | `Core.CoreSTDOUT` (one concrete singleton) | `Union{IOStream,TTY,TCPSocket,PipeEndpoint,IOContext{…}×4}` |
| Call-site rework | None | None |
| Binary size | Smallest — 1 backend column, no switch | Constant-factor larger — ~4 backend columns, union-split |
| `IOContext` features (`:color`, `:displaysize`, `:limit`) | **Lost** — singleton carries none | **Kept** |
| `redirect_stdout` | **Impossible** — only the singleton inhabits the type | **Works** for in-union targets (file/pipe/tty/socket) |
| `_reinit_stdio` under trim | Trivial — no fd probe, binding stays the boot singleton | Must run the probe and store a concrete-union result |
| Runtime behavior vs. today | Changed — degrades to raw byte sink | Preserved — same concrete types flow as now |

## How to read this

`stdout::CoreSTDOUT` is the **floor** : the smallest possible binary and the simplest implementation (you don’t even need the startup probe — `stdout` just stays `Core.stdout`). The price is that it isn’t behavior-preserving: no color, no `displaysize`-aware show, no `IOContext`, and `redirect_stdout` can’t work at the Julia level because the binding admits exactly one value. Redirection would have to happen at the fd/C layer before launch.

The narrowed union is a **superset of fidelity at a bounded size cost**. It keeps real `TTY`/`IOContext` behavior and keeps Julia-level redirection working, because the binding still admits the full realistic set of backends — it’s just closed instead of open `::IO`. You pay for that with a write/show stack retained for ~4 backends instead of 1, plus a union-split branch at each `print` site.

One important caveat for `stdout::CoreSTDOUT`: it can only ever be a **trim-only** retyping. Applying it to normal Julia would regress the interactive experience (REPL color, sizing, rich display all key off the `TTY`/`IOContext` types). The narrowed union is also trim-only, but it’s behavior-preserving even where it applies, so it’s far less disruptive if the policy ever leaks beyond trim.

## Bottom line

- Want the **absolute smallest** static binary and can live without color/redirection/rich display → `stdout::CoreSTDOUT`. It’s the cleanest spelling of the `Core.stdout` idea and needs no call-site edits.
- Want the binary **small but still behaving like Julia** (color, `displaysize`, working `redirect_stdout`) → the narrowed union, optionally dropping the `IOContext` half by pinning the color policy in the juliac build to shrink toward the `CoreSTDOUT` floor.

They’re two points on one dial: `CoreSTDOUT` is “one column,” the narrowed union is “a handful of columns,” and the build-time color policy is how you slide between them.
