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
| Method | What it does |
|---|---|
ReadAllTextAsync | Reads the rest of a stream as text; a byte-order mark picks the encoding. |
ToByteArrayAsync | Reads the rest of a stream into a byte[]. |
WaitForExitCodeAsync | Waits for a process to exit and returns its exit code, with a cancellation token. |
await process | Awaits a Process directly and returns its exit code (documented on Await anything). |
Use | Switches the current culture and UI culture until the returned handle is disposed. |
Append | Adds percent-encoded path segments to an absolute URL. |
WithQuery | Sets 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.
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();
});
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:
- 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.
- Otherwise the
encodingyou pass is used. - If you pass no encoding (or
null), UTF-8 is used.
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é"
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
nullstream throwsArgumentNullExceptionwithParamName"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.Lengthfor a seekable stream).
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.
await using Stream upload = invoiceFile.OpenReadStream();
byte[] content = await upload.ToByteArrayAsync(cancellationToken);
invoice.AttachPdf(content);
using MemoryStream stream = new MemoryStream(new byte[] { 1, 2, 3, 4, 5 });
stream.Position = 2;
byte[] rest = await stream.ToByteArrayAsync(); // → [3, 4, 5]
- A
nullstream throwsArgumentNullExceptionwithParamName"stream", when the method is called. - An empty stream returns an empty array, never
null. - A cancelled token throws
OperationCanceledException. - The stream stays open.
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.
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?, becauseProcess.Startcan returnnull. Anullprocess throwsArgumentNullExceptionwithParamName"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
OperationCanceledExceptionthe process is still running; kill it yourself if you need to. - A token that is already cancelled throws
OperationCanceledExceptionwhile the process is still running.
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.
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
[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.
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
nullculture throwsArgumentNullExceptionwithParamName"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 (
.resxfiles) also use the new culture inside the scope.
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.
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"
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.
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"
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.
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.
boolastrue/false. Enums by name (Monday). - Numbers,
Guidand otherIFormattablevalues (includingBigIntegerand your ownIFormattabletypes) with the invariant culture:1.5mis1.5on every machine. DateTimeandDateTimeOffsetin the round-trip format"o";DateOnlyasyyyy-MM-dd;TimeOnlyasHH:mm:ss, with a fraction only when there is one.Version,IPAddressandUrithrough theirToString().- A type of your own (outside the
SystemandMicrosoftnamespaces) with a hand-writtenToString()override, such as a strongly-typed ID.
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"
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"
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.
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.
| Input | Exception | ParamName |
|---|---|---|
null URL | ArgumentNullException | uri |
Relative URL, such as new Uri("/api", UriKind.Relative) | ArgumentException | uri |
null segments array | ArgumentNullException | segments |
A null, empty or whitespace-only segment | ArgumentException | segments |
A "." or ".." segment | ArgumentException | segments |
| An empty collection, or a collection inside a collection | ArgumentException | segments |
| A value with no meaningful text form (see below) | ArgumentException | segments |
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
Typeor another reflection object, a delegate, aTask, aLazy<T>, aMemory<T>orReadOnlyMemory<T>; - any other framework type that is not
IFormattable, such asStringBuilder; - a
KeyValuePair, aDictionaryEntry, 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()fromobjectorValueType(its type name).
Uri apiBase = new Uri("https://api.example.com/v1/");
string? userId = null;
Uri url = apiBase.Append("users", userId, "orders"); // throws ArgumentException (segments)
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.
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:
- An object. Its public properties become parameters, exactly as
ToQueryString()writes them. Nested objects becomeAddress.City=...; see nested objects and collections. - Any
IDictionary, such asDictionary<string, string>,Dictionary<string, object?>orHashtable. - Any
IEnumerable<KeyValuePair<string, TValue>>, where a key may appear more than once.
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"
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"
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"
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:
nullvalues are skipped. Anulldoes not remove an existing parameter:?page=1withnew { page = (int?)null }stays?page=1.- Names match case-sensitively.
?Page=1withnew { 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=1with["first name"] = "z"gives?b=1&first%20name=z. - Spaces are written as
%20, not+. - Formatting is invariant for numbers, dates and other
IFormattablevalues, the same as inAppend. Any other dictionary or pair value is written with itsToString(), which may depend on the current culture. - The fragment is kept:
https://example.com/docs#introwithnew { v = 2 }giveshttps://example.com/docs?v=2#intro. - An object with no public properties (
new object()) leaves the URL unchanged.
| Input | Exception | ParamName |
|---|---|---|
null URL | ArgumentNullException | uri |
| Relative URL | ArgumentException | uri |
null parameters | ArgumentNullException | parameters |
A string ("status=open"), number, bool or other simple value | ArgumentException | parameters |
A collection that is not a dictionary or key/value pairs, such as List<int> | ArgumentException | parameters |
| A dictionary value that contains a nested collection | ArgumentException | parameters |
| An object graph deeper than 16 levels (usually a cycle) | InvalidOperationException |
WithQuery does not take a query string. To set raw text such as "status=open", use UriBuilder.Query; the exception message says so too.
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 processand the other types you can await. - Objects:
ToQueryString(), the formatterWithQueryuses for objects. - Dictionaries:
ToQueryStringon dictionaries andNameValueCollection. - Conventions: argument checks, invariant formatting and other rules across the library.