Listen up. If you're building deep learning models, understanding Custom Layers in Python is non-negotiable. This is where graphs get compiled, gradients get computed, and raw data turns into intelligence.
1Tf custom layers Part 1
Every layer in tf.keras.layers ā Dense, Conv2D, LSTM ā ultimately boils down to the same shape: take some input tensors, apply a set of learnable weights, and produce an output tensor. A Dense layer specifically computes output = activation(dot(input, weights) + bias). That formula covers an enormous range of use cases, which is why it's the default building block for most networks.
But the built-in layers only cover the math someone already thought to implement. The moment you need something that isn't a standard affine transform followed by an activation ā a custom attention mechanism, a physics-informed constraint, a layer that mixes two inputs with a learned gating function ā the pre-built layers stop being enough. You can't bolt a new formula onto Dense; you have to build your own layer from scratch.
This is where Keras's subclassing API comes in. Instead of being limited to composing existing layers, you write a Python class that plugs directly into the same machinery Keras uses internally, so your custom math gets the same automatic differentiation, weight tracking, and model.fit() compatibility as any built-in layer.
# You cannot do this with standard keras layers.
# You must build a Custom Layer.Graph compiled successfully.
2Tf custom layers Part 2
To build a custom layer, you write a Python class that inherits from tf.keras.layers.Layer ā not tf.keras.Model, which is reserved for composing whole networks, and not raw tf.Module, which lacks the Keras-specific bookkeeping (like automatic weight tracking and get_config serialization) that layers rely on.
The constructor, __init__, is where you store configuration values that don't depend on the shape of the incoming data ā things like self.units, the number of output neurons this layer should produce. Critically, you must call super().__init__() first, because the parent Layer class sets up internal bookkeeping (like the list that will track your trainable weights) that your subclass depends on.
At this stage no actual weight tensors exist yet ā __init__ only records intent. That distinction matters because Keras deliberately defers the creation of weight tensors to a separate method, build(), which only runs once the layer has seen the shape of its actual input.
import tensorflow as tf
class MyMathLayer(tf.keras.layers.Layer):
def __init__(self, units):
super(MyMathLayer, self).__init__()
self.units = unitsGraph compiled successfully.
3Tf custom layers Part 3
It's worth pausing on why tf.keras.layers.Layer specifically is the required base class, since the three options look superficially similar. tf.keras.Model adds training loops, saving, and multi-layer orchestration on top of Layer ā using it for a single mathematical operation is overkill and can confuse Keras about how the object should be treated when nested inside another model.
tf.Module is TensorFlow's lowest-level container for variables; it gives you variable tracking but none of the Keras-specific machinery ā no build() lifecycle hook, no automatic handling of trainable/non_trainable weight lists that model.trainable_variables depends on, no compatibility with the Keras functional API.
tf.keras.layers.Layer sits in exactly the right spot: lightweight enough to represent a single operation, but wired into all the Keras conventions ā build(), call(), weight tracking, serialization ā that let your custom math drop into a Sequential or functional model exactly like Dense or Conv2D would.
# The Base ClassGraph compiled successfully.
4Tf custom layers Part 4
Inside the class, you define a build(self, input_shape) method ā this is where the layer's actual weight tensors get created. Keras calls build() automatically, exactly once, the first time data flows through the layer, and it passes in input_shape so you know the dimensions you're working with before allocating memory.
Inside build(), you call self.add_weight(...), specifying a shape, an initializer (like "random_normal" to start with small random values), and a trainable flag. add_weight doesn't just create a tf.Variable ā it also registers that variable with the layer so Keras automatically includes it in layer.trainable_variables and in checkpoint saving.
Deferring weight creation to build() rather than __init__ is deliberate: at construction time you often don't know the shape of the data the layer will receive (e.g. how many features are in the input). By waiting until the first real batch arrives, input_shape[-1] is known, and the weight matrix can be sized correctly without the caller having to specify it manually.
def build(self, input_shape):
# Create a trainable weight matrix
self.w = self.add_weight(
shape=(input_shape[-1], self.units),
initializer="random_normal",
trainable=True
)Graph compiled successfully.
5Tf custom layers Part 5
A common early mistake is trying to call self.add_weight() inside __init__ instead of build(). It usually 'works' for a quick test because the shape happens to be known, but it breaks the layer's flexibility ā the whole point of separating build() is that the same layer instance should be reusable across inputs of different shapes without you hardcoding a dimension.
build() also only runs once per layer instance, no matter how many times call() fires afterward. TensorFlow tracks whether the layer has already been built (via an internal self.built flag) and skips re-running build() on subsequent calls, so weight creation happens exactly one time even though the layer is invoked on every forward pass.
This single-invocation guarantee is also why weight shapes in a custom layer stay fixed after the first batch: if you later feed the layer an input with a different last-dimension size than what build() saw, TensorFlow will raise a shape-mismatch error rather than silently reallocating the weight matrix.
# The Build MethodGraph compiled successfully.
6Tf custom layers Part 6
Finally, you define call(self, inputs). This is the forward pass ā the method actually invoked every time data flows through the layer, whether that's once during a quick test or millions of times during training. In the simplest custom layer, call() might do nothing more than tf.matmul(inputs, self.w), a raw matrix multiplication against the weight tensor created in build().
Unlike build(), which fires only once, call() runs on every single forward pass ā so whatever operations you write here directly determine both the layer's mathematical behavior and its runtime cost. Anything expensive placed in call() (like recreating a tensor from scratch) gets paid on every batch, which is why weight creation belongs in build() and only the actual math belongs in call().
Because call() is built from ordinary TensorFlow ops (tf.matmul, tf.nn.relu, elementwise arithmetic, and so on), it plugs directly into TensorFlow's automatic differentiation. You never write backpropagation by hand ā as long as call() only uses differentiable TensorFlow operations, gradients with respect to self.w are computed automatically when you call .fit() or use a GradientTape.
def call(self, inputs):
# Raw matrix multiplication!
return tf.matmul(inputs, self.w)Graph compiled successfully.
7Tf custom layers Part 7
It helps to keep the three methods straight by what question each one answers. __init__ answers 'what configuration does this layer need before it ever sees data?' ā things like units, an activation name, or a dropout rate. build() answers 'now that I know the input's shape, what weight tensors do I need to allocate?'. call() answers 'given inputs and my existing weights, what output do I produce?'.
A useful way to internalize this split is to notice that __init__ and build() both run rarely (once each, at construction and at first use), while call() runs constantly ā every forward pass, every training step, every inference request. That asymmetry is exactly why Keras separates configuration, state-creation, and computation into three distinct methods instead of one.
If you ever find yourself unsure which method a line of code belongs in, ask: does this line depend on the shape of the input? If yes, it belongs in build(). Does it produce the layer's actual output from inputs? If yes, it belongs in call(). Everything else ā hyperparameters, flags, sub-layer construction that doesn't need input shape ā belongs in __init__.
# The Call MethodGraph compiled successfully.
8Tf custom layers Part 8
Before moving on, it's worth stress-testing your understanding of one of the most consequential decisions inside build(): whether a given weight should be trainable or not. This distinction is easy to gloss over, because both trainable and non-trainable weights are created the exact same way, with self.add_weight() ā the only difference is a single boolean argument.
Get this wrong in a real model and the failure mode is quiet, not loud: nothing crashes, training just silently corrupts a value that was never meant to change, or a value that should have been learned stays frozen at its initial random value forever.
The next exercise walks through exactly this scenario: a custom layer that needs to track a running counter as data flows through it ā a value that must survive across calls, but must never be treated as something gradient descent is allowed to optimize.
# SYSTEM WARNING:
# ADA Protocol initiating...Graph compiled successfully.
9Tf custom layers Part 9
The trainable flag on self.add_weight() controls whether a variable is included in the set of tensors the Optimizer is allowed to touch during backpropagation. When trainable=True, the weight is added to layer.trainable_variables, gradients are computed with respect to it, and every call to optimizer.apply_gradients() nudges its value to reduce the loss.
When trainable=False, the variable still exists as a real tf.Variable ā it still holds state, still persists across calls, still gets saved in checkpoints ā but it's excluded from the gradient computation entirely. The Optimizer never sees it, so no matter how the loss changes, this variable's value only changes when you explicitly assign to it yourself (e.g. with .assign_add()).
This is precisely the mechanism Keras uses internally for things like BatchNormalization's running mean and variance: statistics that need to accumulate over the course of training, but that must never be treated as parameters the loss function gets to optimize directly.
# ADA initializing variable tracking...Graph compiled successfully.
10Tf custom layers Part 10
Consider a custom layer that counts how many images have flowed through it ā a plain integer counter implemented as self.count = self.add_weight(shape=(), initializer="zeros", trainable=False), incremented inside call() with something like self.count.assign_add(tf.cast(tf.shape(inputs)[0], tf.float32)).
If this weight were trainable=True by mistake, it would be pulled directly into the loss function's gradient computation. The Optimizer's entire job is to adjust every trainable variable in whatever direction reduces the loss ā it has no concept of 'this number is just bookkeeping, please don't touch it.' It would treat the counter exactly like a weight matrix and drag its value around during backpropagation, corrupting the tally with values that have nothing to do with counting images.
Setting trainable=False is what tells Keras 'this variable holds state, not a parameter to be learned' ā it stays out of trainable_variables, stays out of the gradient tape's default watch list, and only changes when your own code calls .assign() or .assign_add() on it directly.
# DEFEND THE SYSTEMGraph compiled successfully.
11Tf custom layers Part 11
At this point you have all three pieces of a working custom layer: __init__ to store configuration, build() to create weights sized to the actual input, and call() to define the forward pass using those weights. Together, that's enough to write any mathematical operation Keras doesn't already ship, and have it behave exactly like a built-in layer ā droppable into Sequential, the functional API, or a subclassed Model.
The trainable flag you just worked through is the other half of the picture: it's what lets a custom layer hold state that persists and updates without being part of what the Optimizer learns, which is essential for anything beyond simple stateless math ā running statistics, counters, moving averages, or cached lookups.
With build(), call(), and add_weight(trainable=...) under control, you're equipped to implement the custom layers that show up in real research code and production models, rather than being limited to whatever tf.keras.layers happens to already contain.
print("System secured.\
Math overridden.")Graph compiled successfully.
12Step-by-Step Breakdown
A standard Dense layer does simple math: output = activation(dot(input, weights) + bias). But what if you want to invent a completely new mathematical formula?
To do this, we create a Python Class that inherits from tf.keras.layers.Layer.
When creating a custom layer in TensorFlow, what base class MUST your new Python class inherit from?
- ā
tf.keras.Model - ā
tf.keras.layers.Layer - ā
tf.Module
Inside the class, you must define the build() method. This is where you tell TensorFlow to actually create the RAM variables (the weights and biases) for your layer.
What is the specific purpose of overriding the build(self, input_shape) method in a Custom Layer?
- āIt defines the mathematical forward pass.
- āIt is the exact place where you initialize and instantiate the layer's internal 'state' or 'weights' (e.g., using
self.add_weight()) because it has access to theinput_shape. - āIt calculates the loss.
Finally, you must define the call() method. This is the heart of the layer. It defines exactly what mathematical operations happen to the data when it passes through.
Which method holds the actual mathematical operations (the "forward pass") of your custom layer?
- āThe
__init__()method. - āThe
build()method. - āThe
call(self, inputs)method.
Now, prepare yourself. We are about to enter the ADA Defense Protocol. Ensure you understand training variables vs non-trainable variables.
When using self.add_weight(), there is a critical boolean flag: trainable. If it is True, the Optimizer will change this number. If False, the Optimizer ignores it.
ADA DEFENSE: You are building a custom layer that keeps a running tally of how many images have passed through it. You create a weight for this tally. Why MUST you set trainable=False for this specific weight?
- āBecause you cannot train integers.
- āBecause it is purely a tracking counter. If it were
trainable=True, the Optimizer would ruthlessly alter the counter via backpropagation to minimize the Loss function, destroying your tally. - āBecause Keras only supports 1 trainable weight per layer.
Threat neutralized. Variable states confirmed. Custom logic securely integrated.
Build Real Custom Layer Weights. Finish build_custom_weights(): build() allocates the actual weight matrix.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Not applicable ā this lesson covers Python/TensorFlow code executed server-side or in a Jupyter kernel, not browser-rendered UI.
Not applicable ā TensorFlow custom layers run in a Python runtime, independent of any browser.
Not applicable ā code examples are runnable in any environment with TensorFlow installed (local, Colab, cloud notebook).
Not applicable ā browser choice has no effect on TensorFlow model training or layer execution.
Accessibility (A11y)
1Readable Model Code
Naming custom layer classes and their arguments clearly (e.g. self.units, self.w) makes model architecture reviewable by teammates and screen-reader-friendly when code is shared in documentation or notebooks.
class MyMathLayer(tf.keras.layers.Layer):
def __init__(self, units, name="my_math_layer", **kwargs):
super().__init__(name=name, **kwargs)
self.units = unitsSEO Implications
- 1
High-Intent Developer Search Queries
"how to build a custom keras layer", "tf.keras.layers.Layer build vs call", and "trainable=False keras" are frequent developer search queries, making an accurate, code-grounded explanation of the Layer subclassing API valuable for organic search traffic from working ML engineers.
Best Practices
Create Weights in build(), Not __init__
Defer self.add_weight() calls to build(self, input_shape) so the layer can infer correct weight shapes from the actual input instead of forcing the caller to hardcode a dimension.
Mark Non-Learnable State as trainable=False
Any weight that represents bookkeeping ā counters, running statistics, moving averages ā must be created with trainable=False so the Optimizer never includes it in a gradient update.
Frequent Bugs
Calling self.add_weight() inside __init__ instead of build(), before the input shape is known.
Move weight creation into build(self, input_shape) so the shape is derived from input_shape[-1] rather than hardcoded, keeping the layer reusable across differently-shaped inputs.
Real-World Examples
A Custom Gated Combination Layer
A model needs to combine two input tensors with a learned per-feature gate, a formula that doesn't exist as a built-in Keras layer.
class GatedCombine(tf.keras.layers.Layer):
def build(self, input_shape):
self.gate = self.add_weight(
shape=(input_shape[0][-1],),
initializer="ones",
trainable=True
)
def call(self, inputs):
a, b = inputs
g = tf.sigmoid(self.gate)
return g * a + (1 - g) * b