Getting the last object created
For duplicate operator use context.selected_objects for result

Is the last object to be created always the last element of the
bpy.data.objects list?

Lets run a very simple little test in python console to see: add an empty to the default scene
&gt;&gt;&gt; D.objects[:]
[bpy.data.objects['Camera'], bpy.data.objects['Cube'], bpy.data.objects['Lamp']]

&gt;&gt;&gt; bpy.ops.object.empty_add()
{'FINISHED'}

&gt;&gt;&gt; C.object
bpy.data.objects['Empty']

&gt;&gt;&gt; D.objects[:]
[bpy.data.objects['Camera'], bpy.data.objects['Cube'], bpy.data.objects['Empty'], bpy.data.objects['Lamp']]

The property list appears to be sorted alphabetically.
Use the context

This wouldn't be necessary if all operators which create objects
returned the object(s) created...

In the context - operator paradigm, an operator only returns a status (eg success) by using and altering context. The result is gleened after running from the context.  It is always the case that directly after running a create object operator, the new object is context.object eg.
bpy.ops.mesh.primitive_cube_add()
cube = context.object

bpy.ops.mesh.primitive_cone_add()
cone = context.object

print(cube.name, cone.name)

Your new question.
Firstly recommend never using bpy.data.objects[&quot;Cube&quot;] since there is no assurance that it exists in which case it throws a keyerror or is linked to the context scene. The None state mentioned in question alludes to ob = context.object would be better, or by name scene.objects.get(name)
The question script example in question is creating states as discussed in
Is the active object and zeroth selected object always the same?
For the case of the duplicate operator it returns result in context.selected_objects  If the context.object is None or not selected it is not duplicated, else it becomes the new context object (first time thru loop)
First time thru loop, (ob is  context object to begin) have context object and is only selected after deselect all.
Have new context object &quot;temp&quot; (and selected) after dupe.
Then you remove it. context object -&gt; None.  It does not magically revert to some default object, if you remove the context object context.object is None
End of loop status No context object and none selected... what is the expected result???

The first time through the loop, temp is an object. The second time,
it's None.

Yep, exactly as expected. (if printing context object) Takes a while to grasp the concept of context.
Options.
The duplicate operator takes the selected objects, dupes them and returns as selected, simply
temp = bpy.context.selected_objects[0]

Overide
bpy.ops.object.duplicate(
        {&quot;selected_objects&quot; : [ob]}
        )

temp = context.selected_objects[0]

or
Since interested in one object can  set as the context object at loop start. (as demonstrated in ideasman42's answer) (or end) with
context.view_layer.objects.active = ob

and
temp = context.object
#or
temp = context.selected_objects[0]

a case when context object and zeroth are the same.
Having no context object can affect how other operators, eg entering edit mode funtion. Depending on the script functionality is often good practice to leave context how we found it. eg reinstating the context object and selection.
API
There are more regular low level API methods, which return the object reference, eg adding an empty to context collection.
mt = bpy.data.objects.new(&quot;MT&quot;, None)
context.collection.objects.link(mt)

for the &quot;CtGP's&quot; duplicate case
for i in range(3):
    temp = ob.copy()

Subscribe to msgbus
Have left this in as, was my understanding of first incarnation of question, re finding the last object created with an add primitive object.
If the user has added an object with the UI and we wish to know  the last one :
Similarly to Origins to the down of the object by default and Auto Normals by default  look for the active object changing and check the active operator.
It is the case that most operators that add an object have a bl_idname that ends in &quot;_add&quot;
Finding other objects created by addon operators that do not adhere to this, or added via other scripting technigues is going to be tricky, without constantly keeping track of scene.objects and notifying on change. (Will look for link as have answered in this regard before too)
Here is a test script that sets scene.last_object_created to the object last created using one of blenders add object operators.
import bpy
from bpy.props import PointerProperty

handle = object()

# Triggers when an object is made active
subscribe_to = bpy.types.LayerObjects, &quot;active&quot; # 


def notify_test(context):
    if (getattr(context.active_operator, &quot;bl_idname&quot;, &quot;&quot;).endswith(&quot;_add&quot;)
            ):
        if context.object in context.scene.objects:
            context.scene.last_object_created = context.object

bpy.msgbus.subscribe_rna(
    key=subscribe_to,
    owner=handle,
    args=(bpy.context,),
    notify=notify_test,
)

bpy.msgbus.publish_rna(key=subscribe_to)

bpy.types.Scene.last_object_created = PointerProperty(
        type=bpy.types.Object,
        )

Test in py console after adding text object.
&gt;&gt;&gt; C.active_operator
&lt;bpy_struct, Operator(&quot;OBJECT_OT_text_add&quot;) at 0x7fb73cc7ccc8&gt;

&gt;&gt;&gt; C.scene.last_object_created
bpy.data.objects['Text']

Another idea would be to make a collection of pointer properties to store more than 1 in some order.
Please also note that any use of context is referring to bpy.context