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 tooff
bytes 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:-10
means '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_WRONLY
orO_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
0
means octal. In Go,0755
and0o755
are 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:
x
means you can execute it as a program or script. - On directories:
x
means you can cd into the directory and access its children (with proper read). r
on a dir lets you list names.x
lets you enter.w
lets you create/delete/rename inside. Usually you need bothw
andx
together 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)
}
}