The difference in behaviour your are observing between with_items and loop is actually due to the way with_items flatten the list for you, implicitly, which makes it happen later than when you flatten it explicitly in your loop, as advised by the Ansible documentation.
So, here the difference just stands in the moment where the list is flattened:
Using with_items, the list is flattened internally by Ansible, so it would be an equivalent to:
[ some_var ] | flatten(1)
When, in the loop documentation of Ansible, as you saw it, you have to flatten the list yourself, but by introducing a list one level upper, your flatten happen too early, and, thus, is an equivalent to:
[ some_var | flatten(1) ]
So if you are looking to exactly and explicitly reproduce the same behaviour as with_items in your loop, you have to do this:
- hosts: all
gather_facts: no
tasks:
- debug:
msg: "{{ item }}"
loop: "{{ [ some_var | flatten(1) ] | flatten(1) }}"
vars:
some_var:
- k: key1
m: value1
- k: key2
m: value2
- k: key3
m: value3
Which works, as expected:
PLAY [all] **********************************************************************************************************
TASK [debug] ********************************************************************************************************
ok: [localhost] => (item={'k': 'key1', 'm': 'value1'}) => {
"msg": {
"k": "key1",
"m": "value1"
}
}
ok: [localhost] => (item={'k': 'key2', 'm': 'value2'}) => {
"msg": {
"k": "key2",
"m": "value2"
}
}
ok: [localhost] => (item={'k': 'key3', 'm': 'value3'}) => {
"msg": {
"k": "key3",
"m": "value3"
}
}
PLAY RECAP **********************************************************************************************************
localhost : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
This is because you are creating a list of list in the second example, while the loop only loops over the first level of a list.
So your first, working playbook is an equivalent to looping on the variable debugged here under:
- hosts: all
gather_facts: no
tasks:
- debug:
msg: "{{ some_var | flatten(1) }}"
vars:
some_var:
- k: key1
m: value1
- k: key2
m: value2
- k: key3
m: value3
That gives:
PLAY [all] **********************************************************************************************************
TASK [debug] ********************************************************************************************************
ok: [localhost] => {
"msg": [
{
"k": "key1",
"m": "value1"
},
{
"k": "key2",
"m": "value2"
},
{
"k": "key3",
"m": "value3"
}
]
}
PLAY RECAP **********************************************************************************************************
localhost : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Your second example, though, is an equivalent to:
- hosts: all
gather_facts: no
tasks:
- debug:
msg:
- "{{ some_var | flatten(1) }}"
## Note that this above syntax is actually an equivalent to:
# msg: "{{ [ some_var | flatten(1) ] }}"
vars:
some_var:
- k: key1
m: value1
- k: key2
m: value2
- k: key3
m: value3
That gives:
PLAY [all] **********************************************************************************************************
TASK [debug] ********************************************************************************************************
ok: [localhost] => {
"msg": [
[
{
"k": "key1",
"m": "value1"
},
{
"k": "key2",
"m": "value2"
},
{
"k": "key3",
"m": "value3"
}
]
]
}
PLAY RECAP **********************************************************************************************************
localhost : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
See how you have a list of list in this second example?
So, the first element of the first example is
{'k': 'key1', 'm': 'value1'}
As you expect it, while the first element of the second one, is just a list containing all of your element:
[
{
"k": "key1",
"m": "value1"
},
{
"k": "key2",
"m": "value2"
},
{
"k": "key3",
"m": "value3"
}
]