1) File Descriptors
What & Why
On Unix, every open file, socket, or pipe is just a number called a file descriptor (FD). Think of it like a handle or ticket number given by the OS when you open something. Go’s *os.File is a wrapper around this FD, giving you safe methods. The first three FDs are always the same: 0 = stdin, 1 = stdout, 2 = stderr. Anything else (3, 4, 5…) are files or sockets you open.
package main
import (
"fmt"
"os"
)
func main() {
f, err := os.Open("data.txt") // FD >= 3 typically
if err != nil { panic(err) }
defer f.Close()
fmt.Println("OS FD:", f.Fd()) // e.g., 3
}Cross-platform
f.Fd() on Windows actually returns the underlying HANDLE value. Standard streams (stdin=0, stdout=1, stderr=2) are simulated for consistency. The key point: on Unix, FDs are real numbers; on Windows, they’re compatibility shims around native handles.Lifecycle
Close(). If you forget, the file descriptor stays open until the garbage collector runs, which can leak resources. On a busy server, leaking FDs means 'too many open files' errors. So: open => use => close. Simple rule, critical in production.2) File Offsets & Seeking (lseek)
Seek basics
(*os.File).Seek(off, whence)changes the 'cursor' position in a file. This offset decides where the next read/write happens.Seek(off, io.SeekStart)=> jump tooffbytes from the beginning of the file.Seek(off, io.SeekCurrent)=> move relative to where you are now (negative values go backwards).Seek(off, io.SeekEnd)=> move relative to the end of the file. Example:-10means 'go to 10 bytes before EOF'.
Key points
O_APPEND, writes always go to the end, ignoring the current offset. But Seek still affects reads.package main
import (
"fmt"
"io"
"os"
)
func main() {
f, _ := os.Open("data.txt")
defer f.Close()
// Move to 10 bytes before EOF
pos, _ := f.Seek(-10, io.SeekEnd)
fmt.Println("new position:", pos)
buf := make([]byte, 10)
n, _ := f.Read(buf)
fmt.Println("last bytes:", string(buf[:n]))
}
3) Open Flags
Core flag
O_RDONLY,O_WRONLY,O_RDWR=> pick one access mode: read-only, write-only, or both.O_CREATE=> create the file if it does not exist.O_EXCL=> withO_CREATE, fail if the file already exists. This gives you an atomic 'create new'.O_TRUNC=> truncate the file to zero length if opened with write access (O_WRONLYorO_RDWR).O_APPEND=> every write is forced to the end of the file, no matter what the current offset is.
package main
import (
"log"
"os"
)
func main() {
// RW, create if missing
f1, err := os.OpenFile("data1.txt", os.O_RDWR|os.O_CREATE, 0644)
if err != nil {
log.Fatal(err)
}
defer f1.Close()
// Exclusive create (fail if exists)
f2, err := os.OpenFile("data2.txt", os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
if err != nil {
log.Println("exclusive create failed:", err)
}
if f2 != nil {
f2.Close()
}
// Create or truncate
f3, _ := os.OpenFile("data3.txt", os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0666)
f3.Close()
}
4) Permissions, Special Bits & umask
Octal perms & umask
Permissions are expressed in octal (base 8). Split into three groups: user (owner), group, other. Each group has 3 bits: r=4, w=2, x=1. Add them up for a digit. Example: 6 = rw-, 7 = rwx, 5 = r-x.
0644=> user=rw-, group=r--, other=r--.0755=> user=rwx, group=r-x, other=r-x.- Final mode = requested &^ umask. The umask is a process-wide 'bit filter' the OS applies, usually
022, which removes group/other write. - Leading
0means octal. In Go,0755and0o755are the same literal.
package main
import (
"fmt"
"os"
)
func main() {
// Request 0644; actual will be 0644 &^ umask
// 0644 &^ 0022 = 0644
f, err := os.OpenFile("file.txt", os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
if err != nil {
panic(err)
}
f.Close()
info, _ := os.Stat("file.txt")
// Print just the permission bits in octal (e.g., 0644)
fmt.Printf("actual perms: %04o\n", info.Mode().Perm())
}
Think of umask as subtraction
0666 for new files (rw-rw-rw-) and 0777 for directories (rwxrwxrwx). The umask then removes bits. With umask 022: files become 0644, dirs become 0755. Technically it's a bitwise clear (&^), but subtraction is the easy mental model.Remember
perm argument in Go is a maximum. The OS’s umask decides the final result. Go does not override it. Don’t be surprised if you ask for 0777 but get 0755.Files vs Directories - what `x` really means
- On files:
xmeans you can execute it as a program or script. - On directories:
xmeans you can cd into the directory and access its children (with proper read). ron a dir lets you list names.xlets you enter.wlets you create/delete/rename inside. Usually you need bothwandxtogether to modify entries.
package main
import (
"fmt"
"os"
)
func main() {
// 0755: rwx for user, r-x for group/other => can traverse
if err := os.Mkdir("pub", 0755); err != nil && !os.IsExist(err) {
panic(err)
}
// 0700: only owner can enter
if err := os.Mkdir("private", 0700); err != nil && !os.IsExist(err) {
panic(err)
}
fi, _ := os.Stat("private")
fmt.Printf("private perms: %04o\n", fi.Mode().Perm())
}
// private perms: 0700
Changing permissions at runtime (`Chmod`)
os.Chmod(path, mode) sets permissions. Combine with Stat().Mode().Perm() to read what you currently have. Typical use: make a script executable after writing it.
package main
import (
"fmt"
"os"
)
func main() {
os.WriteFile("tool.sh", []byte("#!/bin/sh\necho hi\n"), 0644)
// Add user-exec bit: 0644 => 0744
if err := os.Chmod("tool.sh", 0744); err != nil {
panic(err)
}
st, _ := os.Stat("tool.sh")
fmt.Printf("tool.sh perms: %04o\n", st.Mode().Perm())
}
// tool.sh perms: 0744
Reading modes & types (stat)
Go’s FileMode combines two things: the permission bits and the type bits. Use info.Mode().Perm() to isolate the 9 permission bits. Use io/fs masks like ModeSymlink, ModeSocket, etc. to test for type.
package main
import (
"fmt"
"io/fs"
"os"
)
func main() {
info, err := os.Lstat("file.txt")
if err != nil {
panic(err)
}
mode := info.Mode()
fmt.Printf("perms: %04o
", mode.Perm())
switch {
case mode.IsDir():
fmt.Println("type: directory")
case mode.IsRegular():
fmt.Println("type: regular file")
}
if mode&fs.ModeSymlink != 0 {
fmt.Println("type: symlink")
}
if mode&fs.ModeSocket != 0 {
fmt.Println("type: socket")
}
if mode&fs.ModeNamedPipe != 0 {
fmt.Println("type: FIFO")
}
if mode&fs.ModeDevice != 0 {
fmt.Println("type: device")
}
if mode&fs.ModeSetuid != 0 {
fmt.Println("setuid set")
}
if mode&fs.ModeSetgid != 0 {
fmt.Println("setgid set")
}
if mode&fs.ModeSticky != 0 {
fmt.Println("sticky set")
}
}
5) Managing Files
WriteFile & ReadFile
package main
import (
"fmt"
"os"
)
func main() {
// Similar to: f, err := os.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
os.WriteFile("out.txt", []byte("hello"), 0644)
b, err := os.ReadFile("out.txt")
if err != nil {
panic(err)
}
fmt.Println(string(b))
}
Streaming
What is streaming & why use it?
package main
import (
"bufio"
"fmt"
"io"
"os"
)
func lineByLine(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
sc := bufio.NewScanner(f)
// enlarge token limit if needed
buf := make([]byte, 0, 1<<20)
sc.Buffer(buf, 1<<20)
for sc.Scan() {
fmt.Println(sc.Text())
}
return sc.Err()
}
func copyFile(dst, src string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
buf := make([]byte, 1<<20) // 1MiB
_, err = io.CopyBuffer(out, in, buf)
return err
}
func main() {
_ = lineByLine("big.log")
_ = copyFile("b.bin", "a.bin")
}
Why this is streaming
lineByLine and copyFile only keep a small buffer of data in memory at once (a line or a 1 MiB chunk). This lets you process arbitrarily large files or even infinite streams without ever running out of RAM. In contrast, os.ReadFile slurps the whole file into memory.6) Console Input & Output
Reading a number, string (token), and float with fmt.Fscan
fmt.Fscan reads whitespace-separated tokens. Great for quick numeric input (like competitive programming).
package main
import (
"fmt"
"os"
)
func main() {
var n int
var name string
var price float64
fmt.Fprint(os.Stdout, "Enter: <int> <word> <float> => ")
if _, err := fmt.Fscan(os.Stdin, &n, &name, &price); err != nil {
fmt.Fprintln(os.Stderr, "scan error:", err)
return
}
fmt.Printf("n=%d name=%q price=%.2f\n", n, name, price)
}Reading a full line (with spaces)
To capture a whole line of input (with spaces), you usually use a bufio.Scanner. It’s the idiomatic and simplest choice: it splits by newlines and removes the \n or \r\n. If you need more control (like custom delimiters), you can use bufio.Reader.ReadString, but that returns the newline too, so you must trim it.
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
scanner := bufio.NewScanner(os.Stdin)
fmt.Print("Say something: ")
scanner.Scan()
line := scanner.Text() // newline is already removed
fmt.Println("You said (scanner):", line)
}package main
import (
"bufio"
"fmt"
"os"
"strings"
)
func main() {
in := bufio.NewReader(os.Stdin)
fmt.Print("Say something: ")
line, err := in.ReadString('\n') // includes the newline
if err != nil {
fmt.Fprintln(os.Stderr, "read error:", err)
return
}
line = strings.TrimRight(line, "\r\n") // trim newline for cross-platform
fmt.Println("You said (reader):", line)
}Reading a single character (Unicode-safe)
Use ReadRune to correctly read a Unicode code point (rune). Don’t use ReadByte if you care about non-ASCII input.
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
r := bufio.NewReader(os.Stdin)
fmt.Print("Press a key and Enter: ")
ch, size, err := r.ReadRune()
if err != nil {
fmt.Fprintln(os.Stderr, "read rune error:", err)
return
}
fmt.Printf("rune=%q codepoint=U+%04X bytes=%d\n", ch, ch, size)
}Writing to stdout & stderr
Send normal program output to stdout; send problems or logs to stderr. This separation lets users pipe your output without mixing errors.
package main
import (
"fmt"
"os"
)
func main() {
fmt.Fprintln(os.Stdout, "normal output")
fmt.Fprintln(os.Stderr, "error message")
}7) Environment Variables
Reading & writing env
Environment variables are OS-level KEY=value pairs passed to your process (e.g., PATH, HOME, PORT). Use them for configuration so code and secrets aren’t hard-coded.
package main
import (
"fmt"
"os"
)
func main() {
// Set and get
_ = os.Setenv("APP_MODE", "dev")
fmt.Println("APP_MODE:", os.Getenv("APP_MODE"))
// Lookup with boolean
if v, ok := os.LookupEnv("PORT"); ok {
fmt.Println("PORT is", v)
} else {
fmt.Println("PORT not set")
}
// Enumerate all (KEY=VALUE)
for _, e := range os.Environ() {
fmt.Println(e)
}
}