Skip to main content

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 Stream bundles reading, writing, and seeking into one abstract class, whereas Go splits them by role. You build the combination you need by embedding interfaces, as in io.Closer, io.Seeker, and io.ReadWriter.
  • Read returns io.EOF when it reaches the end of the data. io.EOF is 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, and bufio.Scanner / bufio.Reader (read by line or delimiter).
  • Accept io.Reader / io.Writer rather than concrete types (*os.File) in function parameters, so tests can plug in strings.NewReader or bytes.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"`
}
TagMeaning
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) and json.NewEncoder / json.NewDecoder (streams). Use Encoder / Decoder for HTTP bodies and files.
  • JSON of unknown shape can be read into any, which turns objects into map[string]any and numbers into float64.
  • json.RawMessage lets you defer interpreting part of the document. Define custom formats with MarshalJSON / UnmarshalJSON methods.

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-dd but with the reference time Mon Jan 2 15:04:05 MST 2006. 2006-01-02 15:04:05 corresponds to yyyy-MM-dd HH:mm:ss.
  • Common layouts are defined as constants such as time.RFC3339, time.DateOnly (Go 1.20 and later), and time.DateTime (Go 1.20 and later).
  • time.Duration is an int64 count of nanoseconds, built by multiplying by constants like 2 * time.Second. Multiplying by a variable needs a conversion: time.Duration(n) * time.Second.
  • time.Time carries a time zone (Location) internally, and time.Now() returns local time. For storage and comparison, normalizing with UTC() is safer.
  • Compare with t.Equal(u), t.Before(u), and t.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 with t.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.

NeedStandardWhen to consider external
HTTP server / clientnet/httpLots of route grouping (chi, etc.)
JSONencoding/jsonVery high performance needed (fast JSON libraries)
Structured logginglog/slog (Go 1.21 and later)Integration with an existing logging stack (zap, zerolog, etc.)
Testingtesting, net/http/httptestSimpler assertions (testify), mock generation
Command-line argumentsflagCLIs with many subcommands (cobra, etc.)
Config files / env varsos.Getenv, encoding/jsonYAML / TOML, hierarchical config (viper, etc.)
Collection operationsslices, maps (Go 1.21 and later)Specialized data structures
Databasesdatabase/sqlORMs 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.Jsonencoding/json
System.IO.Streamio.Reader / io.Writer
DateTime / TimeSpantime.Time / time.Duration
Console / String.Formatfmt

Additional mappings that complement the table above:

.NET (C#)Go
File.ReadAllText / File.WriteAllTextos.ReadFile / os.WriteFile
StreamReader.ReadLinebufio.Scanner / bufio.Reader
Stream.CopyToio.Copy
MemoryStreambytes.Buffer / bytes.Reader
StringBuilderstrings.Builder
string.Split / Join / Containsstrings.Split / strings.Join / strings.Contains
int.Parse / int.TryParsestrconv.Atoi / strconv.ParseInt (return errors as values)
Environment.GetEnvironmentVariableos.Getenv / os.LookupEnv
Environment.GetCommandLineArgsos.Args / the flag package
Path.Combinepath/filepath.Join
Regexregexp (RE2 syntax; no backreferences or lookahead)
List<T>.Sort / LINQ OrderByslices.Sort / slices.SortFunc
Dictionary<TKey, TValue>.Keysmaps.Keys (returns an iterator; Go 1.23 and later)
Stopwatchtime.Now() and time.Since
Task.Delay / PeriodicTimertime.After / time.NewTimer / time.NewTicker
ILoggerlog/slog
Math.Max / Math.MinBuilt-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 / NUnittesting

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 %w in fmt.Errorf, you can still identify the original error with errors.Is and errors.As.
  • Write diagnostic messages to os.Stderr, and for abnormal termination return a status code with os.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)
}
  • slog takes alternating keys and values: slog.Info("message", "key", value, ...).
  • cmp.Or is 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.Writer is 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/json is controlled by struct tags. Only exported fields participate, unknown fields are ignored, and numbers read into any become float64.
  • With time, write layouts using the reference time 2006-01-02 15:04:05, and compare with Equal / Before / After. Multiplying a Duration by a variable needs a conversion.
  • Call Close with defer, handle errors as return values, wrap them with %w, and identify them with errors.Is / errors.As.
  • Prefer the newer standard packages: log/slog for logging and slices / maps for 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.

References​