Standard Library Overview
For developers with .NET (C#) experience, this guide introduces the key standard packages such as fmt, io, encoding/json, time, and os. Go's standard library is designed around small, composable parts, and the abstractions centered on io.Reader / io.Writer connect files, networks, compression, hashing, and much more. Instead of a large class hierarchy like the BCL (Base Class Library), Go gets a lot done with a handful of interfaces. Because the standard library already covers an HTTP server, JSON, testing, and structured logging, "try the standard library first" is the basic Go attitude.
1. What This Document Covers
- The io.Reader / io.Writer abstraction
- encoding/json and struct tags
- Gotchas of the time package
- Criteria for deciding "is the standard library enough?"
The io.Reader / io.Writer abstraction
Go I/O is built on two small interfaces.
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
- Files (
*os.File), network connections, HTTP request/response bodies,strings.Reader,bytes.Buffer, and compression, encryption, and hashing types all implement them. - C#'s
Streambundles reading, writing, and seeking into one abstract class, whereas Go splits them by role. You build the combination you need by embedding interfaces, as inio.Closer,io.Seeker, andio.ReadWriter. Readreturnsio.EOFwhen it reaches the end of the data.io.EOFis an error value, but it signals a normal end, not a failure.- Commonly used helpers include
io.Copy(copy from a Reader to a Writer),io.ReadAll(read everything),io.TeeReader,io.MultiWriter,io.LimitReader, andbufio.Scanner/bufio.Reader(read by line or delimiter). - Accept
io.Reader/io.Writerrather than concrete types (*os.File) in function parameters, so tests can plug instrings.NewReaderorbytes.Buffer.
encoding/json and struct tags
encoding/json converts between structs and JSON. What attributes are to System.Text.Json, struct tags are here.
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email,omitempty"`
Password string `json:"-"`
CreatedAt time.Time `json:"created_at"`
}
| Tag | Meaning |
|---|---|
json:"name" | Specifies the JSON key name |
json:"name,omitempty" | Omits the field from the output when the value is the zero value (empty string, 0, nil, empty slice, etc.) |
json:"name,omitzero" | Omits the field when it is the zero value (Go 1.24 and later; also works for struct zero values such as time.Time) |
json:"-" | Excludes the field from serialization |
json:",string" | Encodes numbers or booleans as JSON strings |
- Unexported fields (starting with a lowercase letter) are never encoded or decoded.
- The main APIs are
json.Marshal/json.Unmarshal(in-memory byte slices) andjson.NewEncoder/json.NewDecoder(streams). Use Encoder / Decoder for HTTP bodies and files. - JSON of unknown shape can be read into
any, which turns objects intomap[string]anyand numbers intofloat64. json.RawMessagelets you defer interpreting part of the document. Define custom formats withMarshalJSON/UnmarshalJSONmethods.
Gotchas of the time package
time.Time represents a point in time and time.Duration represents a length of time. They play roles similar to DateTime / TimeSpan, with a few differences.
- Layouts are written not with symbols like
yyyy-MM-ddbut with the reference timeMon Jan 2 15:04:05 MST 2006.2006-01-02 15:04:05corresponds toyyyy-MM-dd HH:mm:ss. - Common layouts are defined as constants such as
time.RFC3339,time.DateOnly(Go 1.20 and later), andtime.DateTime(Go 1.20 and later). time.Durationis anint64count of nanoseconds, built by multiplying by constants like2 * time.Second. Multiplying by a variable needs a conversion:time.Duration(n) * time.Second.time.Timecarries a time zone (Location) internally, andtime.Now()returns local time. For storage and comparison, normalizing withUTC()is safer.- Compare with
t.Equal(u),t.Before(u), andt.After(u)rather than==, because==also compares the time zone and monotonic clock information. - The zero value
time.Time{}is 0001-01-01 00:00:00 UTC. Check for it witht.IsZero(). - Measure elapsed time with
time.Since(start).time.Now()includes a monotonic clock reading, so measurements stay correct even if the wall clock changes.
Criteria for deciding "is the standard library enough?"
In Go, it is common to check whether the standard library can do the job before adding a third-party library.
| Need | Standard | When to consider external |
|---|---|---|
| HTTP server / client | net/http | Lots of route grouping (chi, etc.) |
| JSON | encoding/json | Very high performance needed (fast JSON libraries) |
| Structured logging | log/slog (Go 1.21 and later) | Integration with an existing logging stack (zap, zerolog, etc.) |
| Testing | testing, net/http/httptest | Simpler assertions (testify), mock generation |
| Command-line arguments | flag | CLIs with many subcommands (cobra, etc.) |
| Config files / env vars | os.Getenv, encoding/json | YAML / TOML, hierarchical config (viper, etc.) |
| Collection operations | slices, maps (Go 1.21 and later) | Specialized data structures |
| Databases | database/sql | ORMs or code generation (sqlc, etc.) |
As rules of thumb:
- If the standard library can do it in a few dozen lines, avoiding the extra dependency is easier to maintain in the long run.
- The standard library provides "basic building blocks with compatibility guarantees." Thanks to the Go 1 compatibility promise, code written against it keeps working for a long time.
- Before adopting an external library, check its maintenance status, number of dependencies, and license.
2. .NET Mapping
| .NET (C#) | Go |
|---|---|
| System.Text.Json | encoding/json |
| System.IO.Stream | io.Reader / io.Writer |
| DateTime / TimeSpan | time.Time / time.Duration |
| Console / String.Format | fmt |
Additional mappings that complement the table above:
| .NET (C#) | Go |
|---|---|
File.ReadAllText / File.WriteAllText | os.ReadFile / os.WriteFile |
StreamReader.ReadLine | bufio.Scanner / bufio.Reader |
Stream.CopyTo | io.Copy |
MemoryStream | bytes.Buffer / bytes.Reader |
StringBuilder | strings.Builder |
string.Split / Join / Contains | strings.Split / strings.Join / strings.Contains |
int.Parse / int.TryParse | strconv.Atoi / strconv.ParseInt (return errors as values) |
Environment.GetEnvironmentVariable | os.Getenv / os.LookupEnv |
Environment.GetCommandLineArgs | os.Args / the flag package |
Path.Combine | path/filepath.Join |
Regex | regexp (RE2 syntax; no backreferences or lookahead) |
List<T>.Sort / LINQ OrderBy | slices.Sort / slices.SortFunc |
Dictionary<TKey, TValue>.Keys | maps.Keys (returns an iterator; Go 1.23 and later) |
Stopwatch | time.Now() and time.Since |
Task.Delay / PeriodicTimer | time.After / time.NewTimer / time.NewTicker |
ILogger | log/slog |
Math.Max / Math.Min | Built-in max / min (Go 1.21 and later) |
Guid.NewGuid() | Not in the standard library (generate with crypto/rand, or use an external library) |
| xUnit / NUnit | testing |
3. Common Pitfalls for C# Developers
Unlike Stream, Readers and Writers are small and separate
If you only read, accept an io.Reader; if you only write, an io.Writer. Add io.Seeker or io.ReaderAt only when you need seeking. The convention is to take the smallest interface you need. Also, Read may return fewer bytes than requested, so to read everything use io.ReadAll, io.ReadFull, or a loop.
Don't forget Close
There is no using. For os.Open, an HTTP resp.Body, and similar, write defer f.Close() right after acquiring the resource. For files you write to, you also need to check the error returned by Close (for example, when flushing buffered data fails).
JSON field names stay as the struct's names unless tagged
There is no global setting like JsonNamingPolicy.CamelCase in System.Text.Json. Without a tag, a field named Name is written as "Name". When decoding, Go looks for a matching field case-insensitively.
You can't distinguish a zero value from "not specified"
omitempty also omits 0, empty strings, and false. To distinguish "the value is 0" from "no value was given", use a pointer (*int). Likewise, when decoding, you can't tell whether the field was missing from the JSON or held 0.
Unknown fields are ignored by default
JSON keys that don't match any field are silently ignored. To be strict, use DisallowUnknownFields on json.Decoder.
Numbers read into any become float64
When you read into any or map[string]any without specifying a type, all numbers become float64. Large integers can lose precision, so define a struct or use Decoder.UseNumber.
time.Time layouts are written with a special reference date
time.Now().Format("yyyy-MM-dd") does not work as you expect. Use the elements of the reference time, as in "2006-01-02" (year 2006, month 01, day 02, hour 15, minute 04, second 05). The timeformat check in go vet detects layouts that swap the order of year, month, and day, such as 2006-02-01.
Converting types for time.Duration
time.Sleep(n * time.Second) is a compile error when n is an int. Write time.Duration(n) * time.Second. Constants (2 * time.Second) are untyped, so they work as is.
Strings are UTF-8 byte sequences
len(s) is the number of bytes, not characters. for i, r := range s iterates over runes (Unicode code points), and i is the byte offset. To count Japanese characters, use utf8.RuneCountInString(s) or []rune(s).
When to use fmt.Println versus slog
Use fmt for formatted output, and use log/slog for structured operational logs from services. slog takes attributes as key-value pairs and can emit formats such as JSON.
The standard log versus slog
The log package is an older API with no levels or structure. In new code, choose slog.
4. Code Examples
Example 1: Combining io.Reader / io.Writer
C#:
using var input = File.OpenRead("input.txt");
using var output = File.Create("copy.txt");
using var sha = SHA256.Create();
// To hash while copying, read and write the buffer manually
var buffer = new byte[8192];
int n;
while ((n = input.Read(buffer, 0, buffer.Length)) > 0)
{
output.Write(buffer, 0, n);
sha.TransformBlock(buffer, 0, n, null, 0);
}
sha.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
Console.WriteLine(Convert.ToHexString(sha.Hash!));
In Go, io.MultiWriter lets you write to several Writers at once.
package main
import (
"crypto/sha256"
"fmt"
"io"
"os"
)
func copyWithHash(dstPath, srcPath string) (string, error) {
src, err := os.Open(srcPath)
if err != nil {
return "", err
}
defer src.Close()
dst, err := os.Create(dstPath)
if err != nil {
return "", err
}
h := sha256.New() // hash.Hash implements io.Writer
// Build a Writer that writes to both dst and h, and copy with io.Copy
if _, err := io.Copy(io.MultiWriter(dst, h), src); err != nil {
dst.Close()
return "", err
}
// Return the Close error too so write failures aren't missed
if err := dst.Close(); err != nil {
return "", err
}
return fmt.Sprintf("%x", h.Sum(nil)), nil
}
func main() {
sum, err := copyWithHash("copy.txt", "input.txt")
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
fmt.Println(sum)
}
Next is an example that reads line by line with bufio.Scanner. With strings.NewReader, you can try the same code without a file.
package main
import (
"bufio"
"fmt"
"io"
"strings"
)
func countLines(r io.Reader) (int, error) {
sc := bufio.NewScanner(r)
n := 0
for sc.Scan() {
fmt.Println(sc.Text())
n++
}
return n, sc.Err() // check read errors with Err
}
func main() {
n, err := countLines(strings.NewReader("one\ntwo\nthree\n"))
fmt.Println(n, err)
}
Because countLines takes an io.Reader, it works as is with a file, an HTTP body, or standard input (os.Stdin).
Example 2: The basics of encoding/json
C# (System.Text.Json):
public record User(
[property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("email")]
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Email);
var json = JsonSerializer.Serialize(new User(1, "Alice", null));
var user = JsonSerializer.Deserialize<User>(json);
Go:
package main
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email *string `json:"email,omitempty"` // omitted when nil
Password string `json:"-"` // never written
CreatedAt time.Time `json:"created_at"`
}
func main() {
u := User{
ID: 1,
Name: "Alice",
Password: "secret",
CreatedAt: time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC),
}
// struct -> JSON
b, err := json.MarshalIndent(u, "", " ")
if err != nil {
panic(err)
}
fmt.Println(string(b))
// JSON -> struct (reject unknown fields with a Decoder)
in := `{"id": 2, "name": "Bob", "created_at": "2025-03-04T05:06:07Z", "extra": true}`
dec := json.NewDecoder(strings.NewReader(in))
dec.DisallowUnknownFields()
var got User
if err := dec.Decode(&got); err != nil {
fmt.Println("decode error:", err) // "extra" is an unknown field, so this fails
}
// When reading without a defined type, numbers become float64
var m map[string]any
if err := json.Unmarshal([]byte(`{"n": 1}`), &m); err != nil {
panic(err)
}
fmt.Printf("%T\n", m["n"]) // float64
// Writing to a stream (also usable for HTTP responses and files)
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
enc.Encode(map[string]int{"count": 3})
}
json.Marshal returns an error. It occurs, for example, when the value contains types that cannot be represented in JSON, such as channels or functions, or when a MarshalJSON method returns an error.
Example 3: The time package
C#:
var sw = Stopwatch.StartNew();
var timeout = TimeSpan.FromSeconds(30);
var t = DateTime.ParseExact("2025-01-02", "yyyy-MM-dd", CultureInfo.InvariantCulture);
Console.WriteLine(t.AddDays(7).ToString("yyyy-MM-dd HH:mm:ss"));
Go:
package main
import (
"fmt"
"time"
)
func main() {
start := time.Now()
timeout := 30 * time.Second
// Parsing: write the layout with the reference time 2006-01-02 15:04:05
t, err := time.Parse("2006-01-02", "2025-01-02")
if err != nil {
panic(err)
}
// Add and format
next := t.AddDate(0, 0, 7)
fmt.Println(next.Format("2006-01-02 15:04:05"))
fmt.Println(next.Format(time.RFC3339))
// Compare with Equal / Before / After
fmt.Println(next.After(t), next.Equal(t))
// Durations
d := 90 * time.Minute
fmt.Println(d, d.Hours()) // 1h30m0s 1.5
n := 3
wait := time.Duration(n) * time.Second // multiplying by a variable needs a conversion
fmt.Println(wait, timeout)
// Checking for the zero value
var zero time.Time
fmt.Println(zero.IsZero())
// Measuring elapsed time
fmt.Println(time.Since(start) < time.Second)
// Working with time zones
jst := time.FixedZone("JST", 9*60*60)
fmt.Println(time.Date(2025, 1, 2, 9, 0, 0, 0, jst).UTC())
}
You can also use the IANA time zone database via time.LoadLocation("Asia/Tokyo"). When the runtime environment has no time zone data (such as a minimal container image), blank-importing the time/tzdata package (import _ "time/tzdata") embeds the data in your binary.
Example 4: fmt, errors, os, and flag
package main
import (
"errors"
"flag"
"fmt"
"io/fs"
"os"
)
type Point struct {
X, Y int
}
func main() {
// fmt verbs
p := Point{1, 2}
fmt.Printf("%v\n", p) // {1 2}
fmt.Printf("%+v\n", p) // {X:1 Y:2}
fmt.Printf("%#v\n", p) // main.Point{X:1, Y:2}
fmt.Printf("%T\n", p) // main.Point
fmt.Printf("%q\n", "hello") // "hello"
// Command-line arguments
name := flag.String("name", "world", "name to greet")
verbose := flag.Bool("v", false, "verbose output")
flag.Parse()
fmt.Printf("hello, %s (verbose=%v)\n", *name, *verbose)
// Environment variables: use LookupEnv to tell "unset" from "empty string"
if v, ok := os.LookupEnv("APP_ENV"); ok {
fmt.Println("APP_ENV =", v)
}
// Reading a file and checking the error
data, err := os.ReadFile("config.json")
if errors.Is(err, fs.ErrNotExist) {
fmt.Println("config.json not found; using default settings")
} else if err != nil {
fmt.Fprintln(os.Stderr, "failed to read:", err)
os.Exit(1)
} else {
fmt.Println(len(data), "bytes")
}
// Wrapping errors (%w)
wrapped := fmt.Errorf("load config: %w", fs.ErrNotExist)
fmt.Println(wrapped, errors.Is(wrapped, fs.ErrNotExist))
}
- When you wrap an error with
%winfmt.Errorf, you can still identify the original error witherrors.Isanderrors.As. - Write diagnostic messages to
os.Stderr, and for abnormal termination return a status code withos.Exit(1).
Example 5: log/slog, slices, and maps
C#:
logger.LogInformation("user {UserId} logged in from {Ip}", userId, ip);
var names = users.Select(u => u.Name).OrderBy(n => n).ToList();
var keys = dict.Keys.OrderBy(k => k).ToList();
Go:
package main
import (
"cmp"
"log/slog"
"maps"
"os"
"slices"
"strings"
)
type User struct {
Name string
Age int
}
func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
slog.SetDefault(logger)
// Pass attributes as key-value pairs
slog.Info("user logged in", "user_id", 42, "ip", "192.0.2.1")
slog.Debug("cache miss", "key", "user:42")
// A child logger with fixed attributes
reqLogger := logger.With("request_id", "req-1")
reqLogger.Warn("slow query", "ms", 1200)
users := []User{{"Carol", 30}, {"Alice", 25}, {"Bob", 25}}
// Sort by age, then by name (similar to LINQ's OrderBy().ThenBy())
slices.SortFunc(users, func(a, b User) int {
return cmp.Or(
cmp.Compare(a.Age, b.Age),
strings.Compare(a.Name, b.Name),
)
})
slog.Info("sorted", "users", users)
// Membership check
names := []string{"Alice", "Bob"}
slog.Info("contains", "result", slices.Contains(names, "Bob"))
// Turn the map's keys into a sorted slice (Go 1.23 and later)
m := map[string]int{"b": 2, "a": 1, "c": 3}
keys := slices.Sorted(maps.Keys(m))
slog.Info("keys", "keys", keys)
}
slogtakes alternating keys and values:slog.Info("message", "key", value, ...).cmp.Oris available since Go 1.22. It returns the first non-zero value, which makes multi-key sorting concise.- Map iteration order is unspecified. To get stable output, extract the keys and sort them.
5. Summary
- The standard library is built from small parts around a few interfaces. In particular,
io.Reader/io.Writeris the shared abstraction that connects files, networks, HTTP, compression, and hashing. - Accept the smallest interface you need rather than a concrete type, which makes functions easier to test and reuse.
encoding/jsonis controlled by struct tags. Only exported fields participate, unknown fields are ignored, and numbers read intoanybecomefloat64.- With
time, write layouts using the reference time2006-01-02 15:04:05, and compare withEqual/Before/After. Multiplying aDurationby a variable needs a conversion. - Call
Closewithdefer, handle errors as return values, wrap them with%w, and identify them witherrors.Is/errors.As. - Prefer the newer standard packages:
log/slogfor logging andslices/mapsfor collection operations. - Before adding a third-party library, check whether the standard library is enough. Thanks to the Go 1 compatibility promise, the standard library is stable over the long term.