Skip to main content

Streams, Processes, Culture and URIs

This page covers small helpers for the edges of an application: reading a Stream to its end, waiting for a Process with a timeout, running a block of code under a different CultureInfo, and building Uri values without hand-written string concatenation. Reach for them in request handlers, deployment scripts, tests and HTTP clients.

Each class lives in the namespace of the type it extends (System.IO, System.Diagnostics, System.Globalization and System), so you do not need an extra using directive.

At a glance​

MethodWhat it does
ReadAllTextAsyncReads the rest of a stream as text; a byte-order mark picks the encoding.
ToByteArrayAsyncReads the rest of a stream into a byte[].
WaitForExitCodeAsyncWaits for a process to exit and returns its exit code, with a cancellation token.
await processAwaits a Process directly and returns its exit code (documented on Await anything).
UseSwitches the current culture and UI culture until the returned handle is disposed.
AppendAdds percent-encoded path segments to an absolute URL.
WithQuerySets query parameters on an absolute URL, replacing parameters with the same name.

Streams​

Both stream methods read from the stream's current position to its end. They never dispose the stream: the code that opened it still owns it.

ReadAllTextAsync​

ReadAllTextAsync(encoding, cancellationToken) reads the rest of the stream as a string. Use it for a request body, a downloaded file or an embedded resource, where you would otherwise create a StreamReader with leaveOpen: true by hand. Both parameters are optional.

Read a webhook payload in a minimal API
app.MapPost("/webhooks/payments", async (HttpRequest request, CancellationToken cancellationToken) =>
{
string payload = await request.Body.ReadAllTextAsync(cancellationToken: cancellationToken);
// request.Body is still open here; ASP.NET Core disposes it.
return Results.Accepted();
});
Read from the current position
using MemoryStream stream = new MemoryStream(Encoding.UTF8.GetBytes("skip:keep"));
stream.Position = 5;

string rest = await stream.ReadAllTextAsync(); // → "keep"
bool stillOpen = stream.CanRead; // → true

The encoding is chosen like this:

  1. If the stream starts with a byte-order mark (UTF-8, UTF-16 or UTF-32), the mark picks the encoding. The mark is not part of the result.
  2. Otherwise the encoding you pass is used.
  3. If you pass no encoding (or null), UTF-8 is used.
Choose the encoding of a legacy export file
Encoding latin1 = Encoding.GetEncoding("ISO-8859-1");
using MemoryStream export = new MemoryStream(new byte[] { 0x63, 0x61, 0x66, 0xE9 });

string text = await export.ReadAllTextAsync(latin1); // → "café"
A UTF-16 byte-order mark wins over the default
byte[] bytes = Encoding.Unicode.GetPreamble().Concat(Encoding.Unicode.GetBytes("héllo")).ToArray();
using MemoryStream stream = new MemoryStream(bytes);

string text = await stream.ReadAllTextAsync(); // → "héllo"
  • A null stream throws ArgumentNullException with ParamName "stream". It throws when the method is called, before the returned task is awaited.
  • An empty stream, or a stream already at its end, returns "".
  • After the call the stream is at its end (stream.Position == stream.Length for a seekable stream).
Cancellation works on every target

On the .NET 10 build the method calls StreamReader.ReadToEndAsync(cancellationToken). The netstandard2.1 build (used on .NET 8 and 9) has no such overload, so it reads in chunks, passes the token to every read and checks it between chunks. In both builds a cancelled token, or a stream that stalls until the token is cancelled, ends with OperationCanceledException.

ToByteArrayAsync​

ToByteArrayAsync(cancellationToken) copies the rest of the stream into a new byte[]. Use it when an API needs the whole content in memory, for example to hash an uploaded file or store it in a database column. The token is optional.

Store an uploaded invoice PDF
await using Stream upload = invoiceFile.OpenReadStream();
byte[] content = await upload.ToByteArrayAsync(cancellationToken);
invoice.AttachPdf(content);
Only the bytes after the current position are returned
using MemoryStream stream = new MemoryStream(new byte[] { 1, 2, 3, 4, 5 });
stream.Position = 2;

byte[] rest = await stream.ToByteArrayAsync(); // → [3, 4, 5]
  • A null stream throws ArgumentNullException with ParamName "stream", when the method is called.
  • An empty stream returns an empty array, never null.
  • A cancelled token throws OperationCanceledException.
  • The stream stays open.
warning

Both methods load the whole remaining content into memory. Do not use them on unbounded input, such as a request body with no size limit. Limit the size first, or process the stream in chunks.

Processes​

A Process can be awaited directly: int exitCode = await process;. That form is described in Await anything. It has no cancellation, so use WaitForExitCodeAsync when you need a timeout.

WaitForExitCodeAsync​

WaitForExitCodeAsync(cancellationToken) waits for the process to exit and returns its exit code. The token parameter has no default; pass CancellationToken.None if you have none.

Run a database migration with a two-minute limit
ProcessStartInfo startInfo = new ProcessStartInfo("dotnet", "ef database update")
{
UseShellExecute = false,
};

using Process? migration = Process.Start(startInfo);
using CancellationTokenSource timeout = new CancellationTokenSource(TimeSpan.FromMinutes(2));

try
{
int exitCode = await migration.WaitForExitCodeAsync(timeout.Token); // → 0 when the migration succeeds
if (exitCode != 0)
{
throw new InvalidOperationException($"Migration failed with exit code {exitCode}.");
}
}
catch (OperationCanceledException)
{
// '!': a null process throws ArgumentNullException, so it is not null when the wait was cancelled.
migration!.Kill(entireProcessTree: true);
throw;
}
  • The receiver is Process?, because Process.Start can return null. A null process throws ArgumentNullException with ParamName "process", synchronously, when the method is called.
  • A process that has already exited returns its exit code, even when the token is already cancelled.
  • Cancelling stops the wait, not the process. After OperationCanceledException the process is still running; kill it yourself if you need to.
  • A token that is already cancelled throws OperationCanceledException while the process is still running.
info

On the .NET 10 build the method calls Process.WaitForExitAsync, which also waits for redirected output read through OutputDataReceived. The netstandard2.1 build (used on .NET 8 and 9) waits for the Exited event, so the last output events may still be arriving when the await completes.

Culture​

Use​

culture.Use() makes the culture the current culture and the current UI culture, and returns an IDisposable. Disposing it restores both previous cultures. Use it to format text in a specific language, such as an invoice for a customer in another country, and in tests that must not depend on the machine's culture.

Format an invoice total for a German customer
decimal total = 1234.5m;

using (CultureInfo.GetCultureInfo("de-DE").Use())
{
string germanTotal = total.ToString("N1"); // → "1.234,5"
}

string afterwards = total.ToString("N1"); // → "1,234.5" when the previous culture was en-US
Pin the culture in a unit test
[Fact]
public void InvoiceTotal_WhenCultureIsGerman_ShouldUseACommaAsDecimalSeparator()
{
// Arrange
using IDisposable culture = CultureInfo.GetCultureInfo("de-DE").Use();
decimal total = 1234.5m;

// Act
string text = total.ToString("N1");

// Assert
text.Should().Be("1.234,5");
}

Scopes nest. Each handle restores the cultures that were current when it was created.

Nested scopes restore in order
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US");

IDisposable outer = CultureInfo.GetCultureInfo("de-DE").Use();
IDisposable inner = CultureInfo.GetCultureInfo("fr-FR").Use();

inner.Dispose();
string afterInner = CultureInfo.CurrentCulture.Name; // → "de-DE"

outer.Dispose();
string afterOuter = CultureInfo.CurrentCulture.Name; // → "en-US"
  • A null culture throws ArgumentNullException with ParamName "culture".
  • Disposing a handle twice restores only once. The second call does nothing, so it cannot undo a scope that was opened later.
  • Because the UI culture changes too, resource lookups (.resx files) also use the new culture inside the scope.
note

CultureInfo.CurrentCulture flows with the async execution context. Inside the using block, the culture stays in effect across await. A culture set inside an async method does not flow back to its caller, so call Use() and dispose the handle in the same method.

URLs​

Append and WithQuery build URLs from parts. Every path segment and every query key and value is percent-encoded, so user input cannot change the path or add parameters. Both methods return a new Uri; the original is not changed. Both need an absolute URL.

Build a request URL in one expression
Uri apiBase = new Uri("https://api.example.com/");
int userId = 42;

Uri url = apiBase.Append("users", userId, "orders").WithQuery(new { status = "open", page = 2 });
string text = url.AbsoluteUri; // → "https://api.example.com/users/42/orders?status=open&page=2"
Read the encoded form with AbsoluteUri

Uri.AbsoluteUri shows the URL exactly as it is sent. Uri.ToString() returns a display form that can show some escaped characters decoded (for example %20 as a space). The examples on this page use AbsoluteUri.

Append​

Append(params object?[] segments) adds path segments to the end of the URL's path. It handles the slash between the base path and the new segments, keeps an existing query string and fragment, and encodes each segment on its own.

Join segments with exactly one slash
Uri apiBase = new Uri("https://api.example.com/v1/");

string orders = apiBase.Append("users", 42, "orders").AbsoluteUri;
// → "https://api.example.com/v1/users/42/orders"

string noSlash = new Uri("https://api.example.com/v1").Append("users").AbsoluteUri;
// → "https://api.example.com/v1/users"

string withQuery = new Uri("https://example.com/api?key=1#top").Append("items", 7).AbsoluteUri;
// → "https://example.com/api/items/7?key=1#top"
tip

new Uri(baseUri, "users") follows RFC 3986: without a trailing slash on the base, the last base segment is replaced, so https://api.example.com/v1 plus users gives https://api.example.com/users. Append always adds to the path, with or without a trailing slash.

Encoding rules​

Each segment is encoded with Uri.EscapeDataString. A / inside a value is encoded as %2F, so it stays one segment. A .. inside a value cannot climb out of the base path.

User input stays inside one segment
Uri apiBase = new Uri("https://api.example.com/v1/");

string file = apiBase.Append("files", "a/b").AbsoluteUri;
// → "https://api.example.com/v1/files/a%2Fb"

string attack = apiBase.Append("files", "../admin").AbsoluteUri;
// → "https://api.example.com/v1/files/..%2Fadmin"

string search = apiBase.Append("search", "c# tips?").AbsoluteUri;
// → "https://api.example.com/v1/search/c%23%20tips%3F"

Values are written as text like this:

  • Strings as they are. bool as true / false. Enums by name (Monday).
  • Numbers, Guid and other IFormattable values (including BigInteger and your own IFormattable types) with the invariant culture: 1.5m is 1.5 on every machine.
  • DateTime and DateTimeOffset in the round-trip format "o"; DateOnly as yyyy-MM-dd; TimeOnly as HH:mm:ss, with a fraction only when there is one.
  • Version, IPAddress and Uri through their ToString().
  • A type of your own (outside the System and Microsoft namespaces) with a hand-written ToString() override, such as a strongly-typed ID.
Many kinds of values
Uri apiBase = new Uri("https://api.example.com/v1/");
Guid id = new Guid("6f9619ff-8b86-d011-b42d-00c04fc964ff");

string mixed = apiBase.Append(DayOfWeek.Monday, 'x', 1.5m, true, id).AbsoluteUri;
// → "https://api.example.com/v1/Monday/x/1.5/true/6f9619ff-8b86-d011-b42d-00c04fc964ff"

string framework = apiBase.Append(new Version(2, 0), IPAddress.Parse("::1")).AbsoluteUri;
// → "https://api.example.com/v1/2.0/%3A%3A1"
A strongly-typed ID with its own ToString()
public readonly record struct OrderId(int Value)
{
public override string ToString()
{
return Value.ToString(CultureInfo.InvariantCulture);
}
}

Uri apiBase = new Uri("https://api.example.com/v1/");
string url = apiBase.Append("orders", new OrderId(42)).AbsoluteUri;
// → "https://api.example.com/v1/orders/42"
Culture of your own ToString()

Append cannot control the culture of a hand-written ToString(). If it formats a decimal with the current culture, the segment changes with the machine: 1.5 gives 1%2C5 under de-DE and 1.5 under en-US. Format with CultureInfo.InvariantCulture inside ToString(), or implement IFormattable.

Collections expand into segments​

An array or list argument (any IEnumerable except a string) adds one segment per item. Each item follows the same rules.

One segment per item
Uri apiBase = new Uri("https://api.example.com/v1/");
int[] ids = new[] { 1, 2 };

string expanded = apiBase.Append("users", ids, "orders").AbsoluteUri;
// → "https://api.example.com/v1/users/1/2/orders"

List<string> parts = new List<string> { "a b", "c/d" };
string encoded = apiBase.Append(parts).AbsoluteUri;
// → "https://api.example.com/v1/a%20b/c%2Fd"

What throws​

Append never skips a missing value. Dropping it would silently change which resource the URL points to: users/{id}/orders would become users/orders.

InputExceptionParamName
null URLArgumentNullExceptionuri
Relative URL, such as new Uri("/api", UriKind.Relative)ArgumentExceptionuri
null segments arrayArgumentNullExceptionsegments
A null, empty or whitespace-only segmentArgumentExceptionsegments
A "." or ".." segmentArgumentExceptionsegments
An empty collection, or a collection inside a collectionArgumentExceptionsegments
A value with no meaningful text form (see below)ArgumentExceptionsegments

These values are rejected because their ToString() would put noise, or sensitive text, into the URL:

  • an exception, even your own subclass with its own ToString() (its message could leak);
  • a Type or another reflection object, a delegate, a Task, a Lazy<T>, a Memory<T> or ReadOnlyMemory<T>;
  • any other framework type that is not IFormattable, such as StringBuilder;
  • a KeyValuePair, a DictionaryEntry, a tuple, a dictionary or an anonymous object;
  • a record that keeps the compiler-generated ToString() (OrderId { Value = 42 });
  • a class or struct that inherits ToString() from object or ValueType (its type name).
A missing ID throws instead of changing the path
Uri apiBase = new Uri("https://api.example.com/v1/");
string? userId = null;

Uri url = apiBase.Append("users", userId, "orders"); // throws ArgumentException (segments)
note

Append() with no segments at all, or with an empty object?[], returns the same URL without adding a trailing slash. It still checks that the URL is absolute.

WithQuery​

WithQuery(object parameters) sets query parameters with "with" semantics:

  • A parameter the URL already has with the same name (case-sensitive) is replaced. All its old values are removed, and the new values are added at the end.
  • Other parameters keep their values and their order.
  • New names are added at the end.
Go to page 2 of a search
Uri current = new Uri("https://example.com/users?page=1&q=a&page=3");

string next = current.WithQuery(new { page = 2 }).AbsoluteUri;
// → "https://example.com/users?q=a&page=2"

string added = new Uri("https://example.com/search?lang=en").WithQuery(new { page = 3 }).AbsoluteUri;
// → "https://example.com/search?lang=en&page=3"

parameters can be one of three things:

  1. An object. Its public properties become parameters, exactly as ToQueryString() writes them. Nested objects become Address.City=...; see nested objects and collections.
  2. Any IDictionary, such as Dictionary<string, string>, Dictionary<string, object?> or Hashtable.
  3. Any IEnumerable<KeyValuePair<string, TValue>>, where a key may appear more than once.
An anonymous object
Uri search = new Uri("https://example.com/search");

string url = search.WithQuery(new { q = "c# tips", page = 2 }).AbsoluteUri;
// → "https://example.com/search?q=c%23%20tips&page=2"
A dictionary built from optional filters
Dictionary<string, object?> filters = new Dictionary<string, object?>
{
["q"] = "shoes",
["color"] = null,
["size"] = 42,
};

string url = new Uri("https://example.com/search").WithQuery(filters).AbsoluteUri;
// → "https://example.com/search?q=shoes&size=42"
Key/value pairs with a repeated key
List<KeyValuePair<string, int>> tags = new List<KeyValuePair<string, int>>
{
new KeyValuePair<string, int>("tag", 1),
new KeyValuePair<string, int>("tag", 2),
};

string url = new Uri("https://example.com/items?tag=old&a=1").WithQuery(tags).AbsoluteUri;
// → "https://example.com/items?a=1&tag=1&tag=2"
Collections repeat the key; dates use ISO 8601
string ids = new Uri("https://example.com/items").WithQuery(new { ids = new[] { 1, 2, 3 } }).AbsoluteUri;
// → "https://example.com/items?ids=1&ids=2&ids=3"

object slot = new { day = new DateOnly(2026, 11, 4), at = new TimeOnly(14, 30, 45) };
string slots = new Uri("https://example.com/slots").WithQuery(slot).AbsoluteUri;
// → "https://example.com/slots?day=2026-11-04&at=14%3A30%3A45"

Notes:

  • null values are skipped. A null does not remove an existing parameter: ?page=1 with new { page = (int?)null } stays ?page=1.
  • Names match case-sensitively. ?Page=1 with new { page = 2 } gives ?Page=1&page=2.
  • Existing names are compared decoded, and + counts as a space, as servers read it. ?first+name=a&b=1 with ["first name"] = "z" gives ?b=1&first%20name=z.
  • Spaces are written as %20, not +.
  • Formatting is invariant for numbers, dates and other IFormattable values, the same as in Append. Any other dictionary or pair value is written with its ToString(), which may depend on the current culture.
  • The fragment is kept: https://example.com/docs#intro with new { v = 2 } gives https://example.com/docs?v=2#intro.
  • An object with no public properties (new object()) leaves the URL unchanged.
InputExceptionParamName
null URLArgumentNullExceptionuri
Relative URLArgumentExceptionuri
null parametersArgumentNullExceptionparameters
A string ("status=open"), number, bool or other simple valueArgumentExceptionparameters
A collection that is not a dictionary or key/value pairs, such as List<int>ArgumentExceptionparameters
A dictionary value that contains a nested collectionArgumentExceptionparameters
An object graph deeper than 16 levels (usually a cycle)InvalidOperationException
Setting raw query text

WithQuery does not take a query string. To set raw text such as "status=open", use UriBuilder.Query; the exception message says so too.

Trimming

WithQuery reads properties and key/value pairs through reflection and is marked [RequiresUnreferencedCode], so a trimmed or Native AOT app gets a warning at the call site. See Trimming and AOT.

See also​

  • Await anything: await process and the other types you can await.
  • Objects: ToQueryString(), the formatter WithQuery uses for objects.
  • Dictionaries: ToQueryString on dictionaries and NameValueCollection.
  • Conventions: argument checks, invariant formatting and other rules across the library.