PyTorch Neural Network — The forward() Layer Bug
Loss drops, validation accuracy stuck at 10% random? Layers in forward() create new weights each batch—optimizer updates old ones.
20+ years shipping production ML systems and the infrastructure behind them. Everything here is grounded in real deployments.
- nn.Module is the base class for all PyTorch models — define layers in __init__, data flow in forward
- super().__init__() is mandatory — without it, layers and parameters are not registered and model.parameters() returns empty
- model.to(device) moves all parameters to GPU in one atomic call — never manually move individual weights
- Defining layers inside forward() creates new untrained weights every pass — the optimizer updates weights that are immediately discarded
- state_dict saves only learnable parameters — smaller, portable, and version-independent compared to saving the full model
- model.eval() disables Dropout and freezes BatchNorm running statistics — always call it before inference or validation
Think of building a neural network in PyTorch the way you would design a high-tech sorting facility from scratch. Before the facility processes a single package, you need a blueprint — which rooms exist, how they connect, and what each room does. In PyTorch, that blueprint is the nn.Module class. The __init__ method is where you draw the blueprint: you declare your layers, their sizes, and how they relate to each other. The forward method is where the conveyor belts run — it describes exactly how data moves through the rooms you built. What makes this more powerful than just writing the math yourself is what happens in the background: PyTorch automatically tracks every weight in every room, knows how to move all of them to a GPU in one command, and knows how to adjust them after each batch of packages comes through. You focus on the architecture. PyTorch handles the bookkeeping.
Building a neural network in PyTorch revolves around one central idea: subclassing nn.Module. You define layers in __init__ and the data flow in forward. PyTorch automatically tracks all parameters, moves them to GPU with a single .to(device) call, and integrates cleanly with torch.optim for gradient-based training.
The nn.Module design solves parameter management at scale. Without it, you would manually track thousands of weight matrices, move each to GPU individually, and implement gradient updates by hand. The module system handles all of this through a unified interface: model.parameters() returns every learnable tensor, model.state_dict() serializes the full learnable state, and model.to(device) moves everything atomically — no risk of a weight matrix left behind on CPU while the rest of the model runs on GPU.
The production failure pattern I see most consistently: developers define layers inside forward() instead of __init__. This creates new uninitialized weights on every forward pass. The optimizer updates weights from the previous pass that no longer exist — they were replaced by fresh random tensors when forward() ran again. Training loss can decrease slightly due to random variation, which masks the bug entirely. Validation accuracy stays at random chance. No error is raised. The model trains for 100 epochs and learns nothing.
What Is Building a Neural Network in PyTorch and Why Does It Exist?
Building a neural network in PyTorch is the process of defining a model by subclassing nn.Module — PyTorch's foundational abstraction for everything that involves learnable parameters. It was designed to solve a specific problem: managing the lifecycle of thousands to billions of weight tensors without building that infrastructure yourself every time you train a model.
The architectural separation at the core of nn.Module is deliberate and meaningful. __init__ defines the static structure — which layers exist, their input and output sizes, how they are named. forward defines the dynamic behavior — how a tensor flows through those layers during each call. This separation is what makes the rest of the system work: PyTorch can inspect the model structure without running data through it, serialize only the parameters independently of the forward logic, and move the entire model to GPU atomically with model.to(device).
The key mechanism underneath all of this is Python's __setattr__ override in nn.Module. When you write self.fc1 = nn.Linear(784, 128) in __init__, PyTorch intercepts that assignment, detects that nn.Linear is itself an nn.Module, and registers it in an internal _modules dictionary. When you write self.weight = nn.Parameter(torch.randn(10, 5)), PyTorch detects nn.Parameter and registers it in _parameters. These dictionaries are what model.parameters(), model.state_dict(), and model.to(device) iterate over. None of this works if you skip super().__init__() — the dictionaries are never created, the __setattr__ override is never installed, and every layer you assign to self is just a plain Python attribute that PyTorch cannot see.
The practical consequence at production scale: a model with 100M parameters that is partially on GPU and partially on CPU produces wrong outputs without raising errors. Parameter groups that the optimizer cannot reach do not update. model.parameters() returning fewer tensors than expected is always a registration bug — not a configuration issue.
For 2026 deployments, the nn.Module contract also integrates with torch.compile() — PyTorch's graph compilation path introduced in 2.0 and stabilized through 2.2 and beyond. A properly structured nn.Module compiles cleanly with torch.compile(model), producing kernel fusion and operator overlap that can reduce training time by 30-50% on modern A100 and H100 hardware without changing a line of model code. Models with operations that break the graph — .numpy() calls inside forward, Python data structures used conditionally — either fail to compile or fall back to eager mode silently.
- __init__ defines the static structure — which layers exist, their sizes, and how they are named as attributes
- forward defines the dynamic behavior — how a tensor flows through those pre-built layers on each call
- super().
installs PyTorch's __setattr__ override — without it, layer assignments to self are invisible to the framework__init__() - model.parameters() iterates all registered learnable tensors — you never maintain a manual list of weights
- model.to(device) moves every registered parameter and buffer atomically — no risk of partial GPU placement causing silent type errors
__init__() initializes _parameters, _modules, and _buffers dictionaries and installs the __setattr__ override that makes layer registration automatic.model.parameters(), model.to(device), and model.state_dict().super().__init__() is always the first line of every nn.Module subclass — no exceptions.__init__() is mandatory and must be first — without it, the module cannot register layers, parameters, or buffers.forward() for linear pipelines and the output of each module automatically becomes the input of the nextforward() — Sequential is architecturally incapable of expressing non-linear data flowEnterprise Persistence: Saving and Loading Forge Models
In a production environment, training a model is only part of the story. You need to persist it, version it, load it reliably six months later, and reproduce its inference behavior exactly. Getting this wrong has a specific failure mode that is not immediately obvious: you load a model, it runs inference without any errors, and it produces predictions — predictions that are quietly wrong because Dropout is still active or because you loaded weights into the wrong architecture without noticing.
The core persistence decision in PyTorch is between saving the full model object and saving only the state_dict. torch.save(model, path) uses Python's pickle to serialize the entire model — code, architecture, and weights together. torch.save(model.state_dict(), path) serializes only the learnable parameter tensors as an OrderedDict of name-to-tensor mappings. The state_dict approach is the production standard for three concrete reasons: the file is smaller because no Python code is embedded, it is portable because you can load weights into a model defined anywhere as long as the parameter names match, and it is safer because pickle can execute arbitrary code when deserializing, which is a real attack surface in shared model repositories.
The full checkpoint pattern extends this for training resumption. Saving only model.state_dict() is sufficient for inference deployment, but if you need to resume training from a checkpoint, you also need the optimizer state — Adam's moment estimates are not recomputed from scratch, and resuming without them produces different training dynamics than if training had never stopped. A complete checkpoint includes model state, optimizer state, epoch number, and the best validation metric so you know whether to update your best-model checkpoint.
One detail that bites teams in production: torch.load() defaults to weights_only=False in PyTorch versions before 2.4, which means it will execute arbitrary pickle code. In PyTorch 2.4+, the default changed to weights_only=True for state_dict loading, which is safer. If you are loading state_dicts — which you should be — explicitly pass weights_only=True regardless of version to future-proof your code and prevent security warnings in CI.
model.eval() immediately after loading weights for inference — before moving to device, before the first forward pass. Without it, Dropout randomly zeroes activations and BatchNorm uses batch statistics instead of its learned running statistics. The model will produce different predictions for the same input on every call, and the difference will not be small enough to ignore in production. Treat model.eval() after load_state_dict as a mandatory step in your inference initialization sequence, not an optional call.torch.load() prevents arbitrary pickle execution — use it whenever loading a state_dict from any source you do not fully control.model.state_dict() with torch.save() — smallest file, no code dependency, load with weights_only=Truemodel.state_dict(), optimizer.state_dict(), current epoch, and best validation metric — resuming without optimizer state produces different training dynamicsload_state_dict()torch.jit.script() or torch.jit.trace() and save with torch.jit.save() — produces a self-contained ScriptModule that runs in LibTorch without PythonContainerizing the Forge Model Service
Getting a PyTorch model to run correctly on a developer workstation is step one. Getting it to run correctly in production — on a different machine, a different OS, a different GPU driver, possibly six months from now — is the actual engineering problem. Containerization with Docker is the standard answer, but the details matter more than most tutorials acknowledge.
The version pinning problem is where most teams make their first mistake. Pulling pytorch/pytorch:latest in production means your deployment environment changes every time a new PyTorch release ships. Changes between minor versions can affect numerical precision, change default behaviors for certain operations, and silently alter model outputs. Pin the full triple: PyTorch version, CUDA version, and cuDNN version. These three together determine the exact kernel implementations your model runs on. A mismatch between cuDNN versions on the same PyTorch base can produce numerically different outputs from the same weights.
The image size problem compounds quickly in multi-service deployments. A CUDA-enabled PyTorch runtime image is typically 5-7GB. A CPU-only image is under 1GB. If your inference service runs on CPU-optimized instances — which is common for cost efficiency in steady-state serving — you are pulling 5-7GB per node during deployments when 1GB would be sufficient. This is not a philosophical problem — it translates directly to longer deployment times, higher container registry egress costs, and slower autoscaling response.
The model weight inclusion problem is the third one. Baking a 500MB model file into a Docker image with COPY means every CI build, every image push, and every container pull moves that 500MB. For a team with 10 engineers committing multiple times a day, this accumulates. The correct pattern is to exclude model weights from the image and mount them from a volume, or download them at container startup from an object store like S3 or GCS. This keeps the image lean, makes weight updates independent of image rebuilds, and allows you to run canary deployments with different weight versions without rebuilding images.
Common Mistakes and How to Avoid Them
Most nn.Module bugs fall into a small set of categories. They are not obscure — they appear consistently across codebases from beginners and experienced engineers alike, usually under deadline pressure when someone is focused on getting the model working and skips a step that seemed optional.
Forgetting super(). is the most foundational mistake, and it has a particularly frustrating failure mode: the error often does not surface immediately. You define your model, assign layers to self, and nothing explodes. The failure comes later when __init__()model.parameters() returns an empty iterator, model.to(device) does nothing, or torch.save(model.state_dict()) produces a file with zero keys. By that point, the developer is often deep into debugging the training loop rather than looking at model initialization.
Using Python lists to store layers is the mistake that catches experienced developers. If you have used other frameworks or written Python professionally, using a list of layers feels completely natural — it is idiomatic Python. But a Python list of nn.Module instances is invisible to PyTorch. The parameters in those layers are not in model.parameters(), they are not moved by model.to(device), and the optimizer cannot update them. The model runs, the loss changes slightly due to the layers in the list processing data, and nothing indicates the optimizer is completely ignoring them. Use nn.ModuleList for any list of modules, and nn.ModuleDict for any dictionary of named modules.
The .numpy() inside forward() mistake is common in teams transitioning from NumPy-heavy workflows. It always produces a RuntimeError if the tensor requires gradients, or a silent gradient chain break if you call .detach() first. Both are wrong inside forward(). All computation in forward() must stay in PyTorch tensor operations. If you need NumPy for debugging, do it outside the computation graph after calling .detach().cpu().
One 2026-specific addition worth calling out: with torch.compile() becoming the standard path for production training, any Python-level control flow in forward() that depends on tensor values — not tensor shapes, but actual data values — will prevent the compiler from tracing the graph cleanly. This was always a theoretical concern; now it is a practical one because compile() is in the default training stack for many teams. Keep forward() deterministic in its control flow — conditional branches should depend on constructor arguments, not on runtime tensor contents.
forward(). It does not raise an error. The model runs. The loss changes. Everything looks like it is training. The bug only becomes visible when validation accuracy stays at random chance despite 50 epochs of training — at which point the GPU hours are already spent. The rule is simple and absolute: __init__ builds the structure, forward describes the data flow. Nothing that creates a layer or allocates a parameter belongs in forward().model.parameters() returns zero from those layers, model.to(device) ignores them, and the optimizer cannot update them. Use nn.ModuleList.p.numel() for p in model.parameters() if p.requires_grad) immediately after model construction — any unexpected number indicates a registration bug.forward() create new random weights every call — the optimizer cannot learn from them. Always define layers in __init__.forward() alone does not.super().__init__() is present as the first line. Second, whether any layers are stored in a Python list or dict instead of nn.ModuleList or nn.ModuleDict.backward() requires a scalar starting point.backward()forward(), or whether it is in a Python list that is not used.model.eval() after loading weights and before any inference call.Quantize or Die: Shrinking Your PyTorch Model for Real-World Latency
Your fancy 700MB ResNet might score 97% on validation, but it's a paperweight in production. Latency budgets don't care about your training loop. Quantization is how you get a model that actually fits inside a container and responds under 100ms.
PyTorch gives you three knobs: dynamic, static, and quantization-aware training (QAT). Dynamic is a free lunch for transformers — weights get int8'd on the fly with minimal accuracy loss. Static quantization needs a calibration dataset but buys you faster inference because you pre-compute scales. QAT is for when you can't afford to lose even 0.5% accuracy, but it means re-training with fake-quantized operations.
The real trick? Profile before you quantize. If your bottleneck is memory bandwidth (common on CPUs), quantization doubles throughput. If it's compute-bound (GPU), you need a different strategy — maybe pruning or distillation. Don't guess. Measure.
Shape Mismatches at 3 AM: Debugging Dynamic Tensor Shapes in Production Pipelines
Your training loop handled batch size 32 like a champ. Then your inference endpoint gets a request with sequence length 512 — everything blows up. Shape mismatches are the silent killer of production PyTorch services because the graph compiler traces shapes, not variables.
The fix isn't try-catch. It's explicit shape contracts at the service boundary. Use torch.jit.trace with a representative input, then validate the traced graph's input specs against your API schema. If your model accepts variable-length sequences, you need torch.jit.script and a @torch.jit.script decorator on the collate function — but that means no Python-side control flow unless you rewrite it.
Another trap: Gradients in inference. Forgot and your GPU memory fills up after three requests. Wrap the entire forward pass — not just the model call — in the context manager. And never, ever call torch.no_grad().backward() in an inference path. Seen that. It's a fun Monday morning.
torch.jit.save + torch.jit.load test in your CI pipeline. If the traced graph serializes and deserializes with the same output, you've locked the shape contract.Data Pipeline Backpressure: Why Your GPU Idles While You Debug
Bought a $30K A100 and seeing 15% utilization? Your data pipeline is the bottleneck. The classic mistake: loading images with PIL and transforming them in the same process as the training loop. The GPU finishes a batch in 15ms, then sits idle for 200ms while the CPU decodes and augments the next one.
PyTorch's DataLoader with num_workers > 0 is your first line of defense. But workers sharing the same disk I/O can still stall. Use prefetch_factor=2 to double the prefetch queue. If your dataset is larger than RAM, use .map with an Apache Arrow or LMDB backend — don't rely on OS page cache for random access.
The pro move? Profile the data loading separately. Wrap your DataLoader in a simple loop that doesn't call .backward() and time the __getitem__ calls. If you're spending more than 20% of epoch time on data loading, switch to NVIDIA DALI or write a custom C++ extension for the bottleneck transforms. Your GPU will thank you.
pin_memory=True in DataLoader if you're training on GPU. It enables direct memory transfer to the GPU without CPU-GPU copy stalls. Forgetting this costs you 10-15% throughput.Stop Wasting Time on Import Chains That Bite You in Prod
Every machine learning pipeline starts with imports. Get them wrong, and you'll spend hours debugging ModuleNotFoundError in a Docker container at 2 AM. Don't be that engineer. PyTorch's import structure is deliberate — torch, torch.nn, torch.optim, and torch.utils.data are the core triad. Anything else is a leash you put on yourself.
You don't import the entire torchvision zoo just to load MNIST. You import torchvision.datasets and torchvision.transforms. That's it. The WHY here is dependency discipline: your production image stays lean, your CI builds stay fast, and your teammates don't hate you for pulling in 400 MB of unused CUDA extensions. Think of imports as contracts — only sign what you're going to use, and pin your versions like your job depends on it. Because it does.
from torch import *. It pollutes your namespace and breaks when torch adds internals. Explicit imports are your shield against silent regressions.MNIST Isn't Cute — It's Your Canary in the Data Mine
MNIST is the "hello world" of neural networks, but treat it like a production dataset from day one. The WHY: if you can't load and transform 60,000 handwritten digits reliably, you have zero business scaling to terabyte-sized corpora. Use torchvision.datasets.MNIST with root pointing to a persistent volume, not a temp directory. Your pipeline will thank you when the container restarts and doesn't re-download 11 MB. Set train=True for training split, train=False for test. The download=True flag is a convenience — but in prod, you pre-download and mount. Always.
Transforms are not optional. You need transforms. to convert PIL images to tensors, and ToTensor()transforms.Normalize((0.1307,), (0.3081,)) to standardize the pixel values based on MNIST's global mean and std. Without normalization, your model trains slower and converges to a worse local minimum. That's not theory — that's physics. Load the data, apply the transforms, and move on. The DataLoader handles batching and shuffling. Don't reinvent the wheel. It's round. It works.
download=True once in dev, then switch to download=False and mount the root directory as a read-only volume in staging and prod. It eliminates network failures during training and hardens your data lineage.download=True after your first run.GPU Acceleration
GPU acceleration exists because your 16-core CPU will take hours to train a ResNet-50 on ImageNet while a single NVIDIA A100 crushes it in minutes. PyTorch abstracts CUDA behind a single .to(device) call, but that simplicity masks real traps: data transfer is the bottleneck, not compute. Moving your model and tensors to the GPU is pointless if your dataloader feeds the GPU one batch at a time with CPU-to-GPU copies. Use pin_memory=True in DataLoader and non_blocking=True in .to() to overlap transfers with kernel execution. Always profile with before dispatching, and watch your GPU memory with torch.cuda.is_available()nvidia-smi — silent OOMs kill production pipelines. Mixed precision via torch.cuda.amp gives 2x throughput with minimal accuracy loss. Ignore this and you'll pay cloud GPU costs while your hardware sits idle 60% of the time.
2. Enhancing Data Diversity through Augmentation
Data augmentation exists because neural networks memorize, not generalize — feed them the same 10,000 images rotated identically and they'll fail on a 1-degree shift in production. PyTorch's torchvision.transforms provides geometric and color jitter, but the real win comes from composing augmentations that match your deployment noise: Gaussian blur for camera shake, RandomErasing for occlusions, MixUp for decision boundary smoothing. The torchvision.transforms.RandAugment policy removes guesswork — it samples magnitude and severity randomly per batch. Always apply augmentations on the CPU via DataLoader workers, never the GPU, to avoid starving compute. Test augmentation strength on a holdout set: too weak and you underfit, too strong and you wash out features. Best practice: wrap transforms in a custom nn.Module for serialization with your model.
ToTensor() on GPU wastes pipeline bandwidth. Always run transforms in worker processes.Model trains for 100 epochs but never learns — layers defined inside forward()
model.parameters() shows tensors exist, but their values change by only a tiny amount after 100 epochs of training. The model memorizes nothing and generalizes nothing.forward() method rather than in __init__. Every time forward() was called — once per batch — Python created entirely new nn.Linear instances with freshly randomized weights. The optimizer held references to the weights from the previous forward pass and updated those. On the next forward pass, those updated weights were garbage collected and replaced by new random ones. The model was running inference on different random weights every single batch. The loss decreased slightly in some epochs due to random variation in the new weights, which looked like learning. It was not.forward() to __init__(). The layers are now instantiated once at model creation time and reused across every forward pass. The optimizer holds references to the same weight tensors that the model uses for prediction — updates persist, gradients accumulate correctly, and the model now converges to above 94% validation accuracy within 20 epochs on the same dataset.- Always define layers in __init__, never in
forward()—forward()is called once per batch and should only describe data flow, not create structure - Layers defined in
forward()create new untrained weights every call — the optimizer updates weights that are immediately discarded on the next pass - The symptom is training loss decreasing while validation accuracy stays at random chance — this combination almost always points to either this bug or a data pipeline issue
- Verify with
model.named_parameters()— print parameter values before and after a training step — if they do not change meaningfully, the optimizer is not reaching the weights the model uses
forward() rather than __init__(). Move every nn.Linear, nn.Conv2d, nn.BatchNorm2d, and similar definition to __init__(). forward() should contain only the data flow logic — no layer construction. After fixing, verify by printing a parameter value before and after one optimizer step and confirming it changed.super().__init__() is the first line in your __init__ method. Without it, the internal _parameters, _modules, and _buffers dictionaries are never created. Any assignment of an nn.Module or nn.Parameter to self will raise an error or silently fail to register.model.eval() before inference. Without it, Dropout randomly zeroes activations and BatchNorm uses batch statistics instead of running statistics — both introduce randomness that should be disabled during prediction. Also verify you are not passing data through a training augmentation pipeline during inference.forward() to identify exactly where the mismatch occurs. For single samples, add unsqueeze(0) to add the batch dimension — PyTorch layers expect input shape (batch_size, features), not (features,). Use a dummy tensor at development time to verify shapes before training.model.state_dict().keys() with the keys in the saved checkpoint. Any architecture change — adding a layer, renaming a layer, changing depth — breaks state_dict compatibility. Use strict=False in load_state_dict() only as a diagnostic step to see which keys are mismatched, then fix the architecture to match.python -c "import torch; from your_model import YourModel; m = YourModel(); print(sum(p.numel() for p in m.parameters()))"python -c "from your_model import YourModel; m = YourModel(); print(list(m.named_parameters())[:5])"super().__init__() as the absolute first line of __init__ — parameters, modules, and buffers cannot be registered without it. If you see an empty list from named_parameters(), this is almost always the cause.Key takeaways
__init__() is mandatory and must be the first line of every __init__model.parameters() returns empty, and model.to(device) does nothing.forward() alone does not.Common mistakes to avoid
5 patternsForgetting to call super().__init__() in nn.Module subclass
model.state_dict() returns an empty OrderedDict. Training runs without errors but no weights are being updated. The failure is silent until you check parameter count.super().__init__() as the absolute first line of every __init__ method in every nn.Module subclass. This initializes the _parameters, _modules, _buffers, and _hooks internal dictionaries and installs PyTorch's __setattr__ override, which is what makes layer registration automatic. Without it, self.fc = nn.Linear(...) is just a plain Python attribute assignment.Defining layers inside forward() instead of __init__()
model.named_parameters() shows tensors, but their values change by only a negligible amount across training epochs. No errors are raised anywhere in the training loop.forward() to __init__(). forward() should contain only tensor operations — calls to self.layer_name(x), activation functions, reshapes, and concatenations. Nothing that allocates parameters belongs there.Using a Python list instead of nn.ModuleList for dynamic layers
model.state_dict() does not include those layers, so saving and loading the model silently drops them.model.parameters(), moveable by model.to(device), and serializable by model.state_dict(). Verify the fix by checking parameter count immediately after model construction.Calling model.forward(x) directly instead of model(x)
model.register_forward_hook() do not fire. Backward hooks registered with model.register_backward_hook() do not fire. Debugging and profiling tools that rely on hooks produce no output. In some configurations, autograd tracking setup is incomplete, causing subtle gradient computation issues.forward(), triggers post-forward hooks, and handles training/eval mode bookkeeping. Calling forward() directly bypasses all of this.Converting tensors to NumPy inside forward()
numpy() on a Tensor that requires grad if the tensor is in the computation graph. Or a silent gradient chain break if .detach() is called first — the model runs, computes a loss, calls backward(), but the gradients are zero or None for all layers that produced the detached tensor.forward() as a PyTorch tensor operation. PyTorch has equivalents for nearly every NumPy function — use them. If you genuinely need NumPy values for debugging or post-processing, do the conversion outside the model: output = model(x).detach().cpu().numpy(). The .detach() call must happen outside forward(), after the backward pass is complete.Interview Questions on This Topic
Explain why super().__init__() is non-negotiable in PyTorch. What happens internally to the _parameters and _modules dictionaries?
Module.__init__() initializes several internal dictionaries that are the foundation of the entire parameter management system: _parameters stores nn.Parameter objects (learnable weights and biases), _modules stores child nn.Module instances (sub-layers and sub-networks), _buffers stores non-parameter tensors like BatchNorm's running_mean and running_var, and _hooks stores registered forward and backward hook callbacks. When you write self.fc1 = nn.Linear(10, 5) in __init__, Python calls nn.Module's overridden __setattr__ method. This override inspects the assigned value — if it is an nn.Parameter, it goes into _parameters; if it is an nn.Module, it goes into _modules; otherwise it is a plain Python attribute. Without super().__init__(), these dictionaries are never created. The __setattr__ override is never installed. Every layer you assign to self becomes a plain Python attribute. model.parameters() returns an empty iterator, model.to(device) moves nothing, and model.state_dict() produces an empty dict — all silently, without errors.Frequently Asked Questions
20+ years shipping production ML systems and the infrastructure behind them. Everything here is grounded in real deployments.
That's PyTorch. Mark it forged?
13 min read · try the examples if you haven't